From 66c34f50c039583c8118a072ec52f957f035500d Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Wed, 28 Oct 2026 16:36:27 +1100 Subject: [PATCH] docs: use --block-writes in the copy-and-swap guide and operator docs --quiesced was renamed to --block-writes SCHEMA.TABLE, but the guide merged with the old flag. Operator-facing docs now say offline mode and --block-writes; the engine's internal term, a quiesced target, is kept. --- README.md | 2 +- docs/README.md | 2 +- docs/cli-output-examples.md | 8 ++++---- docs/copy-and-swap.md | 34 ++++++++++++++++++-------------- docs/engine-role.md | 9 +++++---- docs/schemabot-integration.md | 12 +++++------ pkg/migrate/desired_copy_swap.go | 2 +- 7 files changed, 37 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e93649ca..c22b9487 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ ever commits a change — every other command is read-only or fully offline. | Command | Live database | What the connection is used for | |---|---|---| -| `migrate` | required | Resolve the target table, preflight it (privileges, partitioning, size and catalog facts), classify and route the change, then **execute** the routed SQL under bounded budgets. A change routed to copy-and-swap also takes `--free-disk`, and optionally `--quiesced` (fence writes instead of streaming them) and `--restart SCHEMA.TABLE` (start a stopped run over); see [running a copy-and-swap](docs/copy-and-swap.md) | +| `migrate` | required | Resolve the target table, preflight it (privileges, partitioning, size and catalog facts), classify and route the change, then **execute** the routed SQL under bounded budgets. A change routed to copy-and-swap also takes `--free-disk`, and optionally `--block-writes SCHEMA.TABLE` (block the table's writes instead of streaming them, offline mode) and `--restart SCHEMA.TABLE` (start a stopped run over); see [running a copy-and-swap](docs/copy-and-swap.md) | | `migrate --dry-run` | required | The same introspection as a real run — server version, target resolution, table facts — so the printed plan reflects the actual target; executes nothing | | `diff` | required | Introspect the live table (read-only) and materialize the desired-state file on a scratch schema inside a transaction that is always rolled back; prints the plan, changes nothing | | [`pull`](docs/pull.md) | required | Introspect each supported table in a schema and create one desired-state file per table; existing files are never overwritten, and a zero-change `diff` verifies the baseline | diff --git a/docs/README.md b/docs/README.md index 3b28499d..a833815b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ Aurora-only. Why that combination is the product is [vision.md](vision.md); star | [lock-budgeted-passthrough.md](lock-budgeted-passthrough.md) | The **lock-budgeted passthrough contract** — the explicit `--accept-blocking` acknowledgement, typed refusal eligibility, required engine-owned lock and statement budgets, marked verdict and exit code, interruption semantics, and orchestrator boundary. | | [plan-report.md](plan-report.md) | The **plan report contract** — the versioned JSON shape both front doors emit for dry-run plans: fields, closed vocabularies, the fingerprint identity, required consumer behavior for unknown versions/values, and one generated example per source (pinned by test). | | [pull.md](pull.md) | **Export an existing schema** — use `pull` to create one desired-state file per table, then prove the baseline round-trips with a zero-change `diff`. | -| [copy-and-swap.md](copy-and-swap.md) | **Running a copy-and-swap** — the operator's guide: live or quiesced and how to choose, what each run needs, `--free-disk`, and how to stop, resume, and start over. | +| [copy-and-swap.md](copy-and-swap.md) | **Running a copy-and-swap** — the operator's guide: live or offline (`--block-writes`) and how to choose, what each run needs, `--free-disk`, and how to stop, resume, and start over. | | [cli-output-examples.md](cli-output-examples.md) | **CLI output examples** — one real, captured JSON output per shape the CLI produces: the plan report for every dry-run disposition (execute, safer-sequence substitution, copy-and-swap, rewrite-required, backend-unavailable, refusal, destructive), the execution verdicts, exit codes, the linter, and diff. | | [safer-sequences.md](safer-sequences.md) | **Safer-sequence substitution** (the improve path) — how the planner replaces a native-but-blocking form with the ordered online sequence that reaches the same end state: a worked `ADD CONSTRAINT … UNIQUE` comparison (locking, failure modes, transactionality, cost), what the engine adds over running the idiom by hand, the substitutions made today, and the typed caveats. | | [execution-model.md](execution-model.md) | The **execution model** — why safer sequences run autocommit-each-step with no wrapping transaction (PostgreSQL forbids it for the online forms), the **committed prefix** a mid-sequence failure leaves, how the verdict reports the boundary, and the per-sequence partial-failure contracts with their retry paths. Read this to answer "if a multi-step change fails halfway, what state is my table in?" | diff --git a/docs/cli-output-examples.md b/docs/cli-output-examples.md index b5e62e64..606073e5 100644 --- a/docs/cli-output-examples.md +++ b/docs/cli-output-examples.md @@ -391,10 +391,10 @@ $ pg-sprite migrate --alter 'ALTER TABLE users ALTER COLUMN id TYPE text' --free } ``` -With `--quiesced`, the run fences writes to the table instead of streaming them through a -replication slot, needs no logical decoding, and `detail` says the table was quiesced. When -to choose it, and an example of its output, are in -[copy-and-swap.md](copy-and-swap.md#live-or-quiesced). +With `--block-writes SCHEMA.TABLE`, the run fences writes to the table instead of streaming +them through a replication slot, needs no logical decoding, and `detail` says writes to the +table were blocked. When to choose it, and an example of its output, are in +[copy-and-swap.md](copy-and-swap.md#live-or-offline). ### Refused: the engine does not carry the operation yet (`backend-unavailable`) — exit 2 diff --git a/docs/copy-and-swap.md b/docs/copy-and-swap.md index 022d24cd..103f83f0 100644 --- a/docs/copy-and-swap.md +++ b/docs/copy-and-swap.md @@ -12,15 +12,15 @@ and how to stop, resume, and start over. The design is in ## Contents -- [Live or quiesced](#live-or-quiesced) +- [Live or offline](#live-or-offline) - [What each run needs](#what-each-run-needs) - [Free disk space](#free-disk-space) - [Run it](#run-it) - [Stop, resume, and start over](#stop-resume-and-start-over) -## Live or quiesced +## Live or offline -| | Live (default) | Quiesced (`--quiesced`) | +| | Live (default) | Offline (`--block-writes SCHEMA.TABLE`) | | --- | --- | --- | | Application writes during the run | Keep working. pg-sprite reads them from the WAL through a logical replication slot and replays them onto the shadow. | Refused. pg-sprite fences the table with a trigger, and every write fails with SQLSTATE `55006` until the swap commits. | | Server requirements | `wal_level = logical`, a free replication slot, and two free WAL senders | None beyond the copy itself | @@ -30,17 +30,20 @@ and how to stop, resume, and start over. The design is in **Choose live** for a table the application writes to in normal operation, on a server with logical decoding enabled. -**Choose quiesced** when writes to the table are already stopped or can be stopped for the run: +**Choose offline** when writes to the table are already stopped or can be stopped for the run: - archive, history, or reference tables nothing writes to; - a maintenance window, or a feature flag that pauses the table's writer; - a server without logical decoding. On Aurora and RDS that needs `rds.logical_replication = 1`, a parameter that takes a reboot. -`--quiesced` is your declaration that nothing writes the table. pg-sprite does not take it on -trust: the fence refuses any write that does arrive, so nothing is lost, but the application -sees errors for the whole run. The fence and why it also covers replication apply workers are -described in [engine-role.md](engine-role.md#the-write-fence). +`--block-writes` is your declaration that nothing needs to write the table. Its value must name +the table the statement changes, exactly as `SCHEMA.TABLE`; a value naming any other table +stops the run before anything executes, so a flag left in a script cannot take the next +table offline. pg-sprite does not take the declaration on trust: the fence refuses any write +that does arrive, so nothing is lost, but the application sees errors for the whole run. +The fence and why it also covers replication apply workers are described in +[engine-role.md](engine-role.md#the-write-fence). ## What each run needs @@ -69,7 +72,8 @@ $ pg-sprite migrate --free-disk 1GiB --json \ } ``` -Either enable logical decoding, or run with `--quiesced` if the table can take no writes. +Either enable logical decoding, or run with `--block-writes SCHEMA.TABLE` if the table can go +without writes for the run. ## Free disk space @@ -101,8 +105,8 @@ pg-sprite migrate --dry-run \ pg-sprite migrate --free-disk 50GiB \ --alter 'ALTER TABLE public.orders ALTER COLUMN amount TYPE numeric(12,2)' -# Quiesced: nothing writes the table for the length of the run -pg-sprite migrate --free-disk 50GiB --quiesced \ +# Offline: the table's writes are blocked for the length of the run +pg-sprite migrate --free-disk 50GiB --block-writes public.order_archive \ --alter 'ALTER TABLE public.order_archive ALTER COLUMN amount TYPE numeric(12,2)' ``` @@ -115,7 +119,7 @@ A successful run prints one verdict, `executed-via-copy-and-swap`, and exits 0. "outcome": "executed-via-copy-and-swap", "statement": "ALTER TABLE public.order_archive ALTER COLUMN amount TYPE numeric(12,2)", "table": "public.order_archive", - "detail": "verified 5000 rows in 3 chunks; the retained source is dropped; the table was quiesced: writes were fenced for the length of the run" + "detail": "verified 5000 rows in 3 chunks; the retained source is dropped; writes to the table were blocked for the length of the run" } ``` @@ -126,13 +130,13 @@ name for a rollback window; you drop it yourself when the window ends. | You want to | Do this | | --- | --- | -| Stop a running change | Press Ctrl-C, or send `SIGTERM`, once. The run saves where its copy reached and exits. The table keeps its fence (quiesced) or its replication slot (live) until you resume or start over. | -| Resume it | Run the same command again. It continues from where it stopped; nothing is copied twice. | +| Stop a running change | Press Ctrl-C, or send `SIGTERM`, once. The run saves where its copy reached and exits. The table keeps its fence (offline) or its replication slot (live) until you resume or start over. | +| Resume it | Run the same command again, including `--block-writes SCHEMA.TABLE` if the run blocked writes. It continues from where it stopped; nothing is copied twice. | | Start over | Run the same command with `--restart SCHEMA.TABLE`, naming the table exactly. pg-sprite discards the shadow, the fence or slot, and the saved progress, then runs from the beginning. | | Recover from bad data | When the server rejects a row under the new type or a constraint, the run reports `code: data-fault` and tries to abandon itself. Follow the verdict's detail: if it says the run was abandoned, nothing is left behind, so fix the data or the statement and run it again. If it says the run could not be abandoned, the shadow and the fence or slot remain, so fix the data or the statement and run it with `--restart SCHEMA.TABLE`. | ```sh -pg-sprite migrate --free-disk 50GiB --quiesced --restart public.order_archive \ +pg-sprite migrate --free-disk 50GiB --block-writes public.order_archive --restart public.order_archive \ --alter 'ALTER TABLE public.order_archive ALTER COLUMN amount TYPE numeric(12,2)' ``` diff --git a/docs/engine-role.md b/docs/engine-role.md index a2ad7ff4..60531d93 100644 --- a/docs/engine-role.md +++ b/docs/engine-role.md @@ -95,9 +95,10 @@ not every start. ### The write fence -A quiesced copy-and-swap run (`pkg/schemachange.Runner`) does not take the caller's word that -nothing writes the table: it installs a statement-level `BEFORE INSERT OR UPDATE OR DELETE OR -TRUNCATE` trigger, `pgsprite_write_fence`, on the source before its first checkpoint save, and +A copy-and-swap run that blocks writes (`--block-writes`; a quiesced target to +`pkg/schemachange.Runner`) does not take the caller's word that nothing writes the table: it +installs a statement-level `BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE` trigger, +`pgsprite_write_fence`, on the source before its first checkpoint save, and every write the trigger sees is refused with SQLSTATE `55006` (`object_in_use`) and a message naming the table. The trigger is enabled `ALWAYS`, so it fires for a session running under `session_replication_role = replica` as well. The writers that run in that role are the apply @@ -134,7 +135,7 @@ static parameter requiring a reboot), one free slot under `max_replication_slots senders under `max_wal_senders` — the run creates the slot on one replication connection, closes it once the checkpoint row naming the slot commits, and later decodes on a connection of its own, so it holds one sender at a time and the second is headroom. Both apply only to a run -that decodes WAL; a quiesced run skips them. The same check compares the free disk the +that decodes WAL; a run that blocks writes skips them. The same check compares the free disk the caller measured on the database volume — PostgreSQL has no function that reports it — against the shadow copy's headroom, and refuses an unmeasured volume rather than assuming it is large enough. The figure is the diff --git a/docs/schemabot-integration.md b/docs/schemabot-integration.md index 46fc942c..272acb01 100644 --- a/docs/schemabot-integration.md +++ b/docs/schemabot-integration.md @@ -320,7 +320,7 @@ that tests the sentinel first would send an operator-dropped shadow or a held sl | `shadow-statement-target` (from `Runner`, on resume) | The statement `Run` was given names another table than the proven target, caught before the row's statement is compared with it | Configuration: the statement and the target disagree; retrying cannot succeed | | `resume-row-decoding` (from `Runner`) | The row was written by the other kind of run: a target proven without logical decoding was handed a row that names a slot or publication, or is in `catching_up` or `reconciling`, and has no slot to continue it with; a target proven for decoding was handed a row that names no slot, whose source was fenced, not captured | Run the row under the kind of `CopySwapTarget` proof that made it; the proof, not the caller, chooses the route | | `shadow-unrecorded` (from `Runner`) | A relation bears the target's derived shadow name and no row records a run for the target | `DropShadow` the leftover and run again, or run again with `RunOptions.Restart`, which drops it under the run's lock first; a relation the engine did not make is an operator's to rename | -| `resume-shadow-gone` (from `Runner`) | The row's swap has not committed (any phase from `copying` to a `cutover` whose swap rolled back) and it records a shadow under which no relation stands; `ErrShadowNotFound` is reachable through the refusal, and nothing was written | Run the statement again with a restart (`--restart`, `RunOptions.Restart`), which discards the row, its slot or fence, and starts over. By hand, quiesced row: `Store.Delete` the row and run again; the next run builds a fresh shadow, and the source stays fenced until `DropWriteFence` or that run's swap. Live row (it names a slot): `decode.DropSlot` the row's slot, then `Store.Delete` the row, and run again; the slot retains WAL until it is dropped, and a run started while it stands fails creating its own. A live source has no fence | +| `resume-shadow-gone` (from `Runner`) | The row's swap has not committed (any phase from `copying` to a `cutover` whose swap rolled back) and it records a shadow under which no relation stands; `ErrShadowNotFound` is reachable through the refusal, and nothing was written | Run the statement again with a restart (`--restart`, `RunOptions.Restart`), which discards the row, its slot or fence, and starts over. By hand, a row of a run that blocked writes: `Store.Delete` the row and run again; the next run builds a fresh shadow, and the source stays fenced until `DropWriteFence` or that run's swap. Live row (it names a slot): `decode.DropSlot` the row's slot, then `Store.Delete` the row, and run again; the slot retains WAL until it is dropped, and a run started while it stands fails creating its own. A live source has no fence | | `resume-slot-foreign` | The slot of the derived name is physical or another database's; the engine may neither decode from it nor drop it | Operator action on the slot; retrying cannot succeed while it is there | | `resume-slot-held` | The engine's own slot is held by another backend — a previous run still draining, or a tool reading it | Wait and inspect again; a holder that never lets go is an operator action | | `cutover-relation-replaced` (from `Reconcile`) | The source or the shadow is no longer the relation the build recorded, read before the reconcile wrote anything | Stop; the table under the names is not the one this run proved — operator action | @@ -431,8 +431,8 @@ backend ends in exactly one of three verdicts: | Verdict | What it means | Orchestrator action | | --- | --- | --- | -| `outcome: executed-via-copy-and-swap` | The shadow was built, filled, verified, and swapped in under the source's name; `detail` says how many rows and chunks the verification pass covered, whether the retained source was dropped or kept under its `_old` name, whether the run first reconciled a lost slot, and — for a quiesced run — that the source's writes were fenced for the run's length, since exit 0 otherwise reads as online-safe; `writes_blocked: true` says the same for a machine, on this outcome and on a failed run whose fence went on: one that left its fence standing, or that was abandoned after the fence went on. A blocked-writes run that stopped before its fence went on, for example at a table lock another session holds, reports `false`, since the table kept taking writes; its detail says so and that running the statement again starts from the beginning. A run whose done row is saved is this outcome even when what came after failed — `OnCheckpoint` handed the done row, or the release of the lock session; the detail then names that error. The error is nil | Done. Exit 0 at the CLI; the outcome token is distinct from `executed-natively` so an adapter can tell the two paths apart in its record | -| `outcome: refused`, `reason: copy-and-swap-refused` | The route's own gates declined: the preflight found a table shape or an environment it will not run against, or a shadow operation refused mid-run to keep an invariant. `cause` is the route's token unchanged — a `copy-and-swap-…` cause from the preflight, a `shadow-…`, `cutover-…`, `resume-…`, or `copy-…` cause from the shadow operations — and `class` is its routing class. The error is nil | Route on `cause` with the two tables above and [refusal-classes.md](refusal-classes.md#copy-and-swap-refused-keyed-on-copyswaprefusalcause). A preflight refusal touched nothing. A refusal raised after the row was saved — a `cutover-…` or `resume-…` cause — leaves the row, the shadow, and the run's hold on the source in place: the write fence of a quiesced run, or the replication slot of a live run, which pins WAL until it is dropped; the cause says whether the next run clears it or the run is abandoned first. A missing privilege is not this reason: it is the same `insufficient-privileges` refusal the native route reports, with the exact `GRANT` in `detail` | +| `outcome: executed-via-copy-and-swap` | The shadow was built, filled, verified, and swapped in under the source's name; `detail` says how many rows and chunks the verification pass covered, whether the retained source was dropped or kept under its `_old` name, whether the run first reconciled a lost slot, and — for a run that blocked writes (`--block-writes`) — that the source's writes were fenced for the run's length, since exit 0 otherwise reads as online-safe; `writes_blocked: true` says the same for a machine, on this outcome and on a failed run whose fence went on: one that left its fence standing, or that was abandoned after the fence went on. A blocked-writes run that stopped before its fence went on, for example at a table lock another session holds, reports `false`, since the table kept taking writes; its detail says so and that running the statement again starts from the beginning. A run whose done row is saved is this outcome even when what came after failed — `OnCheckpoint` handed the done row, or the release of the lock session; the detail then names that error. The error is nil | Done. Exit 0 at the CLI; the outcome token is distinct from `executed-natively` so an adapter can tell the two paths apart in its record | +| `outcome: refused`, `reason: copy-and-swap-refused` | The route's own gates declined: the preflight found a table shape or an environment it will not run against, or a shadow operation refused mid-run to keep an invariant. `cause` is the route's token unchanged — a `copy-and-swap-…` cause from the preflight, a `shadow-…`, `cutover-…`, `resume-…`, or `copy-…` cause from the shadow operations — and `class` is its routing class. The error is nil | Route on `cause` with the two tables above and [refusal-classes.md](refusal-classes.md#copy-and-swap-refused-keyed-on-copyswaprefusalcause). A preflight refusal touched nothing. A refusal raised after the row was saved — a `cutover-…` or `resume-…` cause — leaves the row, the shadow, and the run's hold on the source in place: the write fence of a run that blocked writes, or the replication slot of a live run, which pins WAL until it is dropped; the cause says whether the next run clears it or the run is abandoned first. A missing privilege is not this reason: it is the same `insufficient-privileges` refusal the native route reports, with the exact `GRANT` in `detail` | | `outcome: failed`, with `code` | The run stopped on an error that is not a refusal; the error is returned alongside the verdict. `code` is `cancelled-by-caller` when the caller's context ended, `invariant-violation` when the engine caught a breach of its own contract, `data-fault` when the server rejected the rows as the statement would shape them — a `*pgconn.PgError` in SQLSTATE class `22` or `23`: a value the new type will not hold, a constraint the copied rows break — and `execution-failed` for everything else — a divergence, an unsettled chunk, a rolled-back swap, a lost lock session, exhausted lock retries, a slot past its lag ceiling | `detail` says what the stop left and what to do with it. A data fault would stop the same way on every resume, so the Runner abandons the run itself before returning (`*schemachange.AbandonedError`): the shadow is dropped, the fence is taken off the source or the slot is dropped, and the checkpoint row is deleted — nothing is held; correct the data or the statement, then run it again without any acknowledgement, and the run starts from the beginning. Every other stop leaves the checkpoint row at its last save with the run's hold on the source — the fence or the slot: a divergence (`*checksum.DivergenceError`) is wedged in `verifying`, and a data fault whose abandon itself failed (the detail says the run "could not be abandoned") is held the same way; for both the detail says to correct the data or the statement and then start the run over with the restart acknowledgement (`--restart SCHEMA.TABLE`; `CopySwapOptions.Restart`), which drops the shadow, takes the fence off the source or drops the slot, deletes the row, and runs the change from the start. Any other stop says that running the same statement again resumes from the row. The `Run` outcome table above is the finer map. Match the wrapped error with `errors.Is`/`errors.As` for the finer routing — the verdict's `code` is the coarse class | The adapter supplies the route's policy on `migrate.Options.CopySwap`: the connection the @@ -499,7 +499,7 @@ metadata before the row reaches an engine-neutral record. A live run that reconc slot writes its `reconciling` marker and the resumed row straight to the store, and the hook sees only the row the reconcile leaves. The hook runs on the run's goroutines and holds the run up while it runs — on the goroutine that called `Run` for the phase saves, with the -table's lock session held and, for a quiesced run, the source fenced, and on the copy's +table's lock session held and, for a run that blocks writes, the source fenced, and on the copy's saving goroutine for the periodic watermark saves, where a slow call delays the next save — so it must be safe to call from either and should return promptly. An error the hook returns stops the run at that save as a `failed` verdict with the `after the checkpoint in phase …` @@ -677,9 +677,9 @@ CLI and through `migrate.Run`, plus one the Runner performs on its own; an orche | Operator need | What to do | What pg-sprite does | | --- | --- | --- | | **Start** a change | `pg-sprite migrate --url … 'ALTER TABLE s.t …'` (with `--free-disk`, and `--block-writes SCHEMA.TABLE` to take the table offline for writes instead of streaming them) | Routes the statement; on the copy-and-swap backend it takes the table's lock session, writes the checkpoint row, builds and fills the shadow, verifies, swaps, and prints one verdict | -| **Stop** a running change | Send `SIGINT` (Ctrl-C) or `SIGTERM` once | The run's context is cancelled and the run unwinds through its own stop path: a copy in flight saves the watermark its committed chunks reached, the table lock session is released, the row stays at its last save with the run's hold on the source — the write fence of a quiesced run, or the replication slot of a live run, which retains WAL until the run is finished or discarded — and the verdict is `failed` with `code: cancelled-by-caller` (exit 1). A second `SIGINT`/`SIGTERM` ends the process at once with exit 130, before the stop path completes; the row then records whatever the last save landed, and the lock session is released by the server when its connection drops | +| **Stop** a running change | Send `SIGINT` (Ctrl-C) or `SIGTERM` once | The run's context is cancelled and the run unwinds through its own stop path: a copy in flight saves the watermark its committed chunks reached, the table lock session is released, the row stays at its last save with the run's hold on the source — the write fence of a run that blocked writes, or the replication slot of a live run, which retains WAL until the run is finished or discarded — and the verdict is `failed` with `code: cancelled-by-caller` (exit 1). A second `SIGINT`/`SIGTERM` ends the process at once with exit 130, before the stop path completes; the row then records whatever the last save landed, and the lock session is released by the server when its connection drops | | **Resume** a stopped change | Run the same statement again, with the same `--block-writes SCHEMA.TABLE` if the stopped run blocked writes | The checkpoint row says where to continue: a stopped copy continues from its watermark, a run past its copy verifies again, a `cutover` row is answered by the catalog (a committed swap finishes from the old-table drop; a rolled-back swap goes back through verify and gate). Nothing is copied twice. A different statement on the same table is refused (`resume-row-mismatch`). A stopped run that blocked writes resumes only with the acknowledgement, and a stopped live run only without it; a run of the other kind is refused (`resume-row-decoding`) and leaves the stopped run's hold as it is | -| **Start over** — discard a stopped change and run it from the beginning | Run the same statement with `--restart SCHEMA.TABLE`, where the value names the resolved target exactly | Under the run's own table lock session, discards what the stopped run left in the order a later restart could meet idempotently — the shadow, then the hold on the source (`DropWriteFence` for a quiesced row, `decode.DropSlot` for a live row), then the checkpoint row — and runs the change fresh. A leftover shadow that no row records is discarded the same way. The decision is written to the audit log before anything is discarded | +| **Start over** — discard a stopped change and run it from the beginning | Run the same statement with `--restart SCHEMA.TABLE`, where the value names the resolved target exactly | Under the run's own table lock session, discards what the stopped run left in the order a later restart could meet idempotently — the shadow, then the hold on the source (`DropWriteFence` for a blocked-writes row, `decode.DropSlot` for a live row), then the checkpoint row — and runs the change fresh. A leftover shadow that no row records is discarded the same way. The decision is written to the audit log before anything is discarded | | **Abandon** a change the data refuses | Nothing — the Runner does it | A run whose fill or swap stops on a data fault (a `*pgconn.PgError` in SQLSTATE class `22` or `23`: a value the new type will not hold, a constraint the copied rows break) would stop the same way on every resume, so the Runner discards it the way `--restart` would — shadow, hold on the source, checkpoint row — under the lock session it still holds, and returns `*schemachange.AbandonedError` wrapping the server's error. The verdict is `failed` with `code: data-fault` and nothing is left on the source. Correct the data or the statement, then run it again; the run starts from the beginning | **When to start over rather than resume.** Most stops resume: a cancellation, a lost lock diff --git a/pkg/migrate/desired_copy_swap.go b/pkg/migrate/desired_copy_swap.go index 87fb60de..3b938b08 100644 --- a/pkg/migrate/desired_copy_swap.go +++ b/pkg/migrate/desired_copy_swap.go @@ -17,7 +17,7 @@ import ( // the router sends in part through the copy-and-swap backend. The router // decides the route from the statement alone; whether the route can run is // a fact about the table and the server — a primary key, no foreign keys or -// triggers or dependent views, logical decoding or a quiesced declaration, +// triggers or dependent views, logical decoding or a block-writes acknowledgement, // disk for the shadow — that only the route's preflight reads. Read it // here, before any statement runs, so a plan the preflight would refuse is // refused whole and the native statements planned ahead of the retype do