Repository navigation
Conversation
…cutover Adds the commands that move the relay processes from one database to the other. - fence: as the relay role, ALTER ROLE CURRENT_USER PASSWORD to a random value (sent as a SCRAM verifier, so no plaintext reaches a server log), then terminate that role's other sessions until two scans in a row find none. No CREATEROLE. - restore-password: Cloud SQL Admin users.update from the side's app secret. - cutover --to dedicated|primary [--dry-run]: checks tracking, sentinel, backlog, a finished copy, the capability mark on every relay session, that no other login role can write relay rows or SET ROLE to the relay role, and the pointer; then writes quiesce with ifGenerationMatch, fences the source at startsAt + 2 s while holding its own relay-role session, drains the dirty set to 0, compares the small tables, scans again for late logins, writes authority generation + 1 on the target and clears quiesce. Any failure before the authority write (15 s abort bar, SIGTERM included) restores the source password over the held session and clears the window. A lost reply on the authority write is undone only after a locked re-read proves it did not land. - status: backlog, sentinel, generations, capability coverage and the pointer. Co-Authored-By: Claude <noreply@anthropic.com>
…s the generation in the pointer
- Reachability gate: from startsAt, every process id seen in the source's relay
sessions (' mv1:<id>') must also hold a session on the target, read as the
relay role there. Any id still missing 5 s into the window aborts the cutover
and names it; the source was never fenced.
- Observable drain: the fence waits until the source's relay sessions (other than
the tool's) show no running statement or open transaction on two scans 100 ms
apart, capped at startsAt + 6 s, where it logs what was still busy. Replaces the
fixed startsAt + 2 s. The abort bar stays at 15 s.
- After the authority write, the pointer's `generation` is CAS-written before the
window clears (only when the write is confirmed, not after a lost reply).
- restore-password refuses a side whose authority generation is below the
pointer's. The cutover's abort path and a rollback cutover (which reopens its
fenced target itself, before the window) restore without that check.
Co-Authored-By: Claude <noreply@anthropic.com>
…rming A dry run passed on a dedicated copy made before tracking was removed and installed again. The cutover now runs the same copy_predates_tracking check as copy and sync, in both directions. Co-Authored-By: Claude <noreply@anthropic.com>
…mark the old side superseded - A rollback cutover that reopened its fenced target and then aborts fences that target again over its relay-role session (or logs `fence --side` if it cannot). - Just before the fence, the cutover marks the source `superseded` (as the tool role); the target's authority write clears its own mark and records its tracking arming. An abort clears the source's mark again. A reopened target that is still superseded is never eligible, which also covers the ~14 s between a successful rollback's reopen and its fence. - The cutover requires a level target in both directions (target_not_current), so a move back refuses once dedicated's tracking was re-armed, until a fresh `copy --from dedicated --restart`. Co-Authored-By: Claude <noreply@anthropic.com>
…fter an aborted rollback Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts: # cloud/apps/relay/src/database-move-cli.ts # cloud/apps/relay/src/database-move-pointer-store.ts
…fresh session; add finish - The cutover no longer writes or clears `superseded`. A lost source session during an abort could leave the mark set on the serving side, and every restarted process would then refuse it. #29405 replaces the mark with a stateless rule. - An aborted rollback fences its reopened target over the cutover's session, or else over a fresh relay-role session, and logs `run: fence --side <side>` if neither works. - finish --keep primary|dedicated runs only when the kept side is the one serving: the highest generation, not below the pointer's, with the relay login working. --keep primary sets the other side's generation to 0, deletes the pointer (CAS), waits two pointer polls, then drops primary's authority and move tables, so processes serve DATABASE_URL alone again. --keep dedicated removes only tracking and bookkeeping, because the pointer and dedicated's authority are how processes find it until DATABASE_URL names dedicated. Then drop the other database through the Cloud SQL API. - status shows whether each side has the authority table. Co-Authored-By: Claude <noreply@anthropic.com>
…teless rule With #29405's rule (a process serves only after a pointer read while primary has the authority table, and the pointer's generation floor always applies), deleting the pointer before dropping primary's table would fail fast every process that re-reads it, and dropping the table first would put primary under the floor. finish --keep primary now clears the other side's generation, sets the pointer's floor to 0 and waits two polls, drops primary's authority and move tables, then deletes the pointer. Tested under a live fleet with 0 failed operations, and a process with no pointer read refuses a primary that still has the table. Co-Authored-By: Claude <noreply@anthropic.com>
A marked relay process with sessions on the target but none on the source had no
old-side session for the gate to read, so the fence could not vouch for it. The
gate now stays shut while any such id is present and aborts with
processes_only_on_the_target {ids} at the reachability deadline.
Co-Authored-By: Claude <noreply@anthropic.com>
|
Closing this public-repository PR because its core is deployment operator tooling, which moved to private I reviewed the full body, production file scope, CLI entry point/import graph and regression intent. This PR provides operator-invoked fencing, password restoration, cutover, status and finish. It is not called by the relay serving path. The new production modules are confined to the database-move CLI graph; this slice changes only the CLI and its password/cutover/fence modules. The tool's migration value survives, so the code and its Postgres race/cutover tests should be carried into the private operations repository if that move is still planned. I have not inspected private implementation or asserted that the database migration is complete. This is an ownership/scope closure, not an algorithm correctness review: I did not exhaustively audit the copy/cutover internals or rerun the operational tests. The separate #29405 changes the active public relay runtime's database-selection contract and is left open; private operator ownership does not invalidate that runtime feature. Relevant public ownership and runtime boundaries are unchanged in HEAD..origin/main. |
ELI5
This is the second half of the database move tool. It does the actual switch. It tells every relay server to pause its database work for a few seconds, locks the old database against the relay, copies the last changes, then tells the servers the new database is in charge. If anything goes wrong before that last step, it unlocks the old database and the servers carry on there. Nothing runs until an operator starts it.
What Changed
For users: nothing until an operator runs
cutover. During a cutover, relay requests wait for a few seconds and then continue; none should fail.How it works (the contract shared with #29405):
fence --side. The tool logs in as the relay role and changes that role's own password to a random one, then ends every other session of that role. It repeats until two checks in a row find none.restore-password --side. Sets the relay role's password back from that side's secret, through the Cloud SQL Admin API (users.update). This is the fallback when the fencing session is gone. It refuses a side whose generation is below the pointer's; a rollback cutover (--to primary) reopens its own fenced target before the window instead.cutover --to dedicated|primary [--dry-run].mv1mark (the tool lists any that don't);mv1:<id>) also holds a session on the target, aborting and naming any still missing 5 s in. It then waits until the source's relay sessions have no running statement or open transaction on two scans 100 ms apart (capped at 6 s, logging what was busy). Then it fences the source while keeping its own relay-role session open.--dry-runruns every check and changes nothing.status. Shows the backlog, the sentinel, the generations, themv1coverage and the pointer.Why
log_statement = ddlnever logs it.Stacked on part 1 (#29462).
Ending a move:
finish --keep primary|dedicated. It runs only when the kept side is the one serving: the highest generation, not below the pointer's, with the relay login working.--keep primary:Primary stays eligible at every step, and afterwards processes serve DATABASE_URL alone, as before the move.
--keep dedicatedremoves only tracking and the move bookkeeping. The pointer and dedicated's authority stay, because they are how processes find dedicated until DATABASE_URL names it.gcloud sql databases delete), which needs no login. Dropping it ends the move on that side.uninstall-trackingthere refuses (side_is_fenced) andrestore-passwordis refused by the generation guard. Both are by design.Staging rehearsal (an Enterprise instance stood in for Enterprise Plus, which was out of capacity in us-central1). Four cutovers under load: primary→dedicated during the ramp, dedicated→primary at saturation, primary→dedicated at steady state, dedicated→primary during the ramp.
/v1/assigncalls returned 200, including 58 during the hold, with a max latency of 0.34 s. Cells had 0 SQL failures.users.updatereturned 403, so the cutover refused at reopen-target before writing anything.Linked Issue
Fixes #
Visual Proof
N/A: an operator command-line tool, no UI.
Testing
Local Postgres 17 and 16:
Fence:
End-to-end cutover:
SwitchingRelayDatabase, against a fake pointer store, with 6 writers doing relay-shaped work (audit inserts, counter updates, rate-window upserts). Timings are scaled down.verify).Refusals:
SET ROLEto the relay role.Abort: a row only the target has makes the comparison fail. The source password is restored, the window is cleared, the processes stay on the source, writes continue with 0 failures, and the target generation stays at 0.
Cloud SQL Admin restore and the GCS pointer store are tested with fake fetches: the URLs, operation polling, 412 on a generation mismatch, and errors that never contain the password.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Agent skill upstream boundary
Notes
Still to prove on staging before the first production use:
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)