Skip to content

feat(relay): database move tool, part 1: change tracking, paced copy, sync and verify - #29462

Closed
Jinwoo-H wants to merge 11 commits into
step4-db-switchfrom
step4-db-move-tool
Closed

Jinwoo-H wants to merge 11 commits into
step4-db-switchfrom
step4-db-move-tool

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​591 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​591
Prod 13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2330 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2329

ELI5

The relay keeps its records in a database it shares with sign-in. We want to move those records to a database of their own without restarting anything. This PR adds the first half of the tool that does the move: it keeps a list of which records changed, copies everything across slowly, keeps the copy up to date, and checks that the two copies match. Nothing runs until an operator starts it.

What Changed

For users: nothing. This is a new command-line tool in the relay image (node apps/relay/dist/database-move-cli.js <command>, meant to run as a Cloud Run job). No running relay process calls it.

How it works:

  • Change list (install-tracking, uninstall-tracking). Inside the relay's own database, a small unlogged table (relay_move_dirty) records the primary key of every changed row. A trigger on each relay table adds the key, for inserts, updates (old and new key) and deletes.
    • The table list comes from the database catalog. The tool refuses if it differs from the tables the relay's own schema creates.
    • The trigger ignores the tool's own role, so the tool's writes never come back as changes.
    • Each table's trigger is added in its own short transaction with a 200 ms lock wait, retried, so a relay write waits at most that long.
    • If the database crashes or fails over, an unlogged table is emptied. A marker row (the "sentinel") shows when that happened, and the sync then stops and asks for a fresh copy.
  • prepare-target refuses unless the pointer object already exists: once primary has relay_database_authority, processes serve only after reading the pointer (feat(relay): follow a database-target pointer with a hold-and-switch between two Postgres targets #29405), and finish ends that period.
  • What prepare-target does. Creates the relay schema on the new database exactly as a director would. Adds the move's bookkeeping tables and the tool role's grants on both sides, then refuses if the two schemas differ (columns, types, keys, collation).
  • copy. Copies each table in primary-key order, parents before children, in small pages, paced to about 1 GB/h of source disk reads (block reads from pg_statio_user_tables, so a bloated table's dead pages count) and a rows/s cap. Rows travel as JSON text that Postgres writes and reads itself, so large numbers keep every digit. The position is saved with each page, so a stopped copy resumes. Each page also removes rows on the target that the source no longer has.
  • sync. Repeatedly takes a batch of changed keys off the list and copies their current rows (or deletes them on the target if they are gone).
    • Taken keys go into a journal first, so a crash between taking and copying loses nothing.
    • Only one sync runs per source, and it refuses to write into the side the relay is serving.
    • It logs the backlog and how far behind it is.
  • Schema drift. Copy and sync re-read both schemas every 30 s and before each copied table; a changed table stops the run and resets its copy position. They also refuse while any table lacks its triggers.
  • Re-arming. Each install-tracking that creates the change list gives it a new id, and each copied table records the id it was copied under. After an uninstall and reinstall, copy, sync and cutover refuse (copy_predates_tracking) until copy --restart redoes the copy, because writes made while tracking was off were never recorded.
  • verify. Compares row counts and checksums over key ranges, and lists the keys that differ.

Why

  • Why triggers, not logical replication: every replication option (native, pglogical, Database Migration Service) needs the cloudsql.logical_decoding flag on the source. Cloud SQL lists that flag as restart-required, so turning it on restarts the shared sign-in database. The staging restart on 10-11 produced 503s. Triggers need nothing from the instance: the relay's own role owns its tables and can add triggers to them.
  • Why ON CONFLICT DO UPDATE in the trigger: it locks the key's row until the writer commits. The sync's FOR UPDATE SKIP LOCKED claim then passes over a key whose change is not committed yet, so a change can never be lost between the sync's read and the writer's commit. The race test below shows DO NOTHING loses changes.
  • Why rows as Postgres JSON: row_to_json → jsonb_populate_recordset keeps types and BIGINT precision with no per-column code.

Part 2 (fence and cutover) is stacked on this PR. This PR is stacked on #29405.

Linked Issue

Fixes #

Visual Proof

N/A: an operator command-line tool, no UI.

Testing

Local Postgres 17 and 16 (two databases with separate roles stand in for the two instances):

  • Trigger race: 6 writers hold transactions open after the trigger marks a key, while the sync claims as fast as it can. Over 6 rounds (about 12,000 writes), the copies match exactly every time. With the trigger switched to DO NOTHING, the copies diverge in the first round.

  • The tool role's writes are not tracked; old and new keys are both marked on a key change.

  • A role other than the owner that writes relay rows keeps working once tracking is on. Without the grant this adds, its insert fails with permission denied.

  • BIGINTs of 2^63−1 and 2^53+1 survive copy and sync exactly.

  • Parents and children (the fence-attempt tables) sync one key per batch in any order, including deletes. A unique value swapped between two keys in one batch settles; the old query failed with a duplicate key.

  • A copy killed mid-table resumes and copies only the rest. A rerun removes rows deleted on the source while untracked.

  • An emptied change list stops the sync until copy --restart.

  • verify names the exact differing keys.

  • CLI: bad flags and missing credentials are refused before any connection.

  • pnpm typecheck passes; the full relay suite (with part 2 on top) passes locally: 167 files, 1,430 tests.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Credentials are Secret Manager version names. URLs and passwords are never logged or put in errors; errors report a SQLSTATE and the server's message only.
  • Added load on the shared instance: one row per changed key in an unlogged table (no WAL), the paced copy, and one or two tool connections.
  • No staging or production change. Running it is gated on Jinwoo's G3.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

… sync and verify

Adds `node apps/relay/dist/database-move-cli.js` to the relay image, so the step-4
move of orca_relay off the shared auth instance can run as a Cloud Run job with no
restart and no flag change on that instance.

- install-tracking / uninstall-tracking: an unlogged relay_move_dirty table with a
  sentinel row, and one statement-level trigger set per relay table (generated
  from the catalog, cross-checked against relayPostgresSchemaStatements()). The
  trigger marks old and new keys with ON CONFLICT DO UPDATE, skips the tool role,
  and is installed one table at a time under a 200 ms lock wait with retries.
- prepare-target: the relay schema on the dedicated side, the authority and copy
  progress tables on both, grants for the tool role, and a schema/collation compare.
- copy: primary-key keyset pages, parents first, rows as Postgres-made JSON text
  (no JavaScript number parsing), paced by bytes/s, resumable from a position saved
  with each page; each page also deletes target rows the source no longer has.
- sync: claims dirty keys with DELETE ... FOR UPDATE SKIP LOCKED into a claimed
  journal (replayed after a crash), reads current rows in one snapshot, applies them
  FK-safe, and stops on a missing sentinel. One syncer per source (advisory lock),
  and never into the side the relay is serving.
- verify: row counts and md5 over primary-key ranges, listing differing keys.

Co-Authored-By: Claude <noreply@anthropic.com>
Jinwoo-H and others added 4 commits October 16, 2026 01:40
…ds, needs every trigger

- copy and sync re-read both sides' tables every 30 s and before each copied
  table. A table whose columns or keys changed stops the run with
  schema_changed_during_run, and its copy position is reset, since rows already
  copied may lack the new column.
- copy is paced on what the source reads from disk for the table (heap, index and
  TOAST block reads from pg_statio_user_tables x block size), or the JSON size if
  larger, plus a rows/s cap (--max-rows-per-second, default 2000). A bloated
  table such as relay_control_connection_reservations costs its dead pages too.
- copy, sync and the cutover refuse while any relay table on the source lacks one
  of its tracking triggers, not only when the sentinel is gone.

Co-Authored-By: Claude <noreply@anthropic.com>
…bility format

#29405 now marks each process as ' mv1:<id>'. The tool's relay-role sessions carry
the same mark with one id per tool run, so they never count as unmarked.

Co-Authored-By: Claude <noreply@anthropic.com>
…t is missing (Postgres < 17)

Co-Authored-By: Claude <noreply@anthropic.com>
Jinwoo-H and others added 6 commits October 16, 2026 05:01
After uninstall-tracking and install-tracking on a side, relay writes made in
between were never recorded, yet the copy position still said every table was
done, so copy reported 0 rows and the dedicated copy stayed stale.

- Each arming of tracking puts a fresh id in the sentinel's key, and every copy
  position records the id it was copied under.
- copy and sync refuse with copy_predates_tracking (run copy --restart) when any
  position on the target was written under another arming, including positions
  from before this change.
- uninstall-tracking on a side a cutover fenced says so, and points at dropping the
  old database through the Cloud SQL API, which needs no login.

Co-Authored-By: Claude <noreply@anthropic.com>
…rget; authority move columns

- uninstall-tracking deletes the sentinel first, in its own commit, so an untrack
  that stops partway leaves no arming for a later install to keep. install-tracking
  also starts a new arming when it finds a table without its triggers.
- sync (and, in part 2, the cutover) refuse with target_not_current unless the
  target has a finished copy under the source's current arming, or the source's
  arming is unchanged since it became the target itself (a move back).
- prepare-target adds `superseded` and `tracking_arm` to relay_database_authority
  for the cutover to write.

Co-Authored-By: Claude <noreply@anthropic.com>
…ed column

- Creating relay_database_authority on primary starts the period in which a process
  serves only after a successful pointer read, so prepare-target now refuses
  unless database-target.json already exists and is readable.
- The `superseded` column is no longer added; the relay moves to a stateless rule
  (#29405). An existing column is left alone.
- The pointer store moves into this PR (prepare-target reads it) and gains a
  generation-matched delete for `finish`.

Co-Authored-By: Claude <noreply@anthropic.com>
@OrcaWin

OrcaWin commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Closing this public-repository PR because its core is deployment operator tooling, which moved to private stablyai/orca-cloud in merged #31792 (307f0cdaeed). The current cloud README explicitly places “operator tools” there while keeping runtime and wire contracts public: https://github.com/stablyai/orca/blob/1a723194c3294298c8db106105d1be226823ae9c/cloud/README.md#deployment-configuration

I reviewed the full body, production file scope, CLI entry point/import graph and regression intent. This PR provides operator-invoked change tracking, paced copy, synchronization and verification. It is not called by the relay serving path. The new production modules are confined to the database-move CLI graph; the shared secret change only extracts the existing text fetch into readSecretManagerText for that CLI, preserving readDedicatedDatabaseCredentials behavior.

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.

@OrcaWin OrcaWin closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants