Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/cli-output-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,8 @@ $ pg-sprite migrate --alter 'ALTER TABLE users ALTER COLUMN id TYPE text' --dry-
Without `--dry-run` the run needs `--free-disk` (the preflight refuses with
`copy-and-swap-refused` / cause `copy-and-swap-disk-headroom` when the shadow
and its indexes would not fit the declared space) and either logical
decoding on the server or `--quiesced`. The verdict's outcome is distinct
decoding on the server or `--block-writes SCHEMA.TABLE`, which names the table
whose writes it blocks until the swap. The verdict's outcome is distinct
from `executed-natively`, and `detail` says what the verification pass
covered and what became of the retained source:

Expand Down
4 changes: 2 additions & 2 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ not escape hatches:
| Retyping or dropping a column a view reads | PostgreSQL refuses `ALTER COLUMN TYPE` and `DROP COLUMN` on a column a view or materialized view reads, even for a type change it would relabel in place. Without `ONLY`, a type change reaches the column on every partition and inheritance child too, and a drop reaches every partition and each inheritance child that does not declare the column and inherits it only from tables the drop also reaches; a child that declares the column or also inherits it from a table outside the drop keeps its copy, while one that inherits it along two paths from the dropped table loses it. The planner reads the views on every copy the change reaches from the catalog and refuses the change with `dependent-view`, naming each view in the plan and in the verdict's `dependent_views`, instead of letting the statement fail at the server. `DROP COLUMN … CASCADE`, which would drop the views with the column, is refused with `cascading-drop`. pg-sprite does not drop and recreate the views for you. Drop or redefine them first, then re-plan. |
| Dropping with `CASCADE` | `CASCADE` on `DROP COLUMN`, `DROP CONSTRAINT`, or `DROP INDEX` also drops whatever depends on the target: a view that reads the column, or another table's foreign key on the constraint. Those objects are not in the statement, so the plan cannot show them and the table's lock does not cover them. The planner refuses every such drop with `cascading-drop`. Drop each dependent object as its own change, then resubmit without `CASCADE`; PostgreSQL then refuses the drop while anything still depends on the target. |
| `ADD COLUMN` on a hot standby, or by a role without `TEMPORARY` | Whether adding a column rewrites the table is the server's answer, read by adding the column to an empty temporary table. A server that accepts no writes, or a role whose `TEMPORARY` on the database was revoked, cannot create that table, so the plan fails with `ErrAddColumnProbeUnavailable` before anything runs. Plan against the primary, or grant `TEMPORARY` on the database to the engine's role. |
| Copy-and-swap | The copy-and-swap backend carries a general `ALTER COLUMN TYPE` (with or without `USING`): the changed shape is built as a shadow table, filled in chunks while writes stream through a logical replication slot, verified, and swapped in under the source's name. The run needs `--free-disk` (the preflight refuses `copy-and-swap-disk-headroom` when the shadow and its indexes would not fit) and either logical decoding (`wal_level = logical`, a free replication slot) or `--quiesced`; its preflight refuses table shapes it cannot run against — no primary key or usable replica identity, unlogged, partitioned, `FORCE ROW LEVEL SECURITY`, foreign keys in either direction, triggers, dependent views, membership in a publication, or a subscription on the database — each as a typed `copy-and-swap-refused` cause ([refusal-classes.md](refusal-classes.md#copy-and-swap-refused-keyed-on-copyswaprefusalcause)). The other rewrite-class operations — volatile-default `ADD COLUMN`, `ADD COLUMN` of a domain with a `CHECK` or `NOT NULL`, `GENERATED … STORED`, `SET TABLESPACE` — route to the backend but it does not carry them yet; they refuse `backend-unavailable`. pg-sprite never falls through to a blocking rewrite. A run that stops short of the swap keeps its hold on the source until it is resumed or abandoned — the write fence of a `--quiesced` run (every write is refused `55006`), or the replication slot of a live run, which pins WAL on the server — and the `failed` verdict's detail says which applies: a stop the next run clears (a lost connection, a cancelled run) resumes from the checkpoint row when the same statement is run again; a stop the data causes meets the same rows again, so it is not resumed: when the server rejects a row under the new type or a constraint (SQLSTATE class `22` or `23`) the run abandons itself — drops the shadow, releases the fence or slot, deletes the checkpoint row — and the verdict is `failed` with `code: data-fault`, so correct the data or the statement and run it again from the beginning; when the shadow and source diverge at verification (or the abandon itself failed), the row and the hold stay and the run has to be started over: correct the data or the statement, then run it with `--restart SCHEMA.TABLE` (`CopySwapOptions.Restart` as a library caller), which drops the shadow, releases the fence or slot, deletes the checkpoint row, and runs the change fresh under the run's own lock ([stopping, resuming, and starting over](schemabot-integration.md#stopping-resuming-and-starting-over)). |
| Copy-and-swap | The copy-and-swap backend carries a general `ALTER COLUMN TYPE` (with or without `USING`): the changed shape is built as a shadow table, filled in chunks while writes stream through a logical replication slot, verified, and swapped in under the source's name. The run needs `--free-disk` (the preflight refuses `copy-and-swap-disk-headroom` when the shadow and its indexes would not fit) and either logical decoding (`wal_level = logical`, a free replication slot) or `--block-writes SCHEMA.TABLE`, which names the table whose writes it blocks until the swap; its preflight refuses table shapes it cannot run against — no primary key or usable replica identity, unlogged, partitioned, `FORCE ROW LEVEL SECURITY`, foreign keys in either direction, triggers, dependent views, membership in a publication, or a subscription on the database — each as a typed `copy-and-swap-refused` cause ([refusal-classes.md](refusal-classes.md#copy-and-swap-refused-keyed-on-copyswaprefusalcause)). The other rewrite-class operations — volatile-default `ADD COLUMN`, `ADD COLUMN` of a domain with a `CHECK` or `NOT NULL`, `GENERATED … STORED`, `SET TABLESPACE` — route to the backend but it does not carry them yet; they refuse `backend-unavailable`. pg-sprite never falls through to a blocking rewrite. A run that stops short of the swap keeps its hold on the source until it is resumed or abandoned — the write fence of a `--block-writes` run (every write is refused `55006`), or the replication slot of a live run, which pins WAL on the server — and the `failed` verdict's detail says which applies: a stop the next run clears (a lost connection, a cancelled run) resumes from the checkpoint row when the same statement is run again; a stop the data causes meets the same rows again, so it is not resumed: when the server rejects a row under the new type or a constraint (SQLSTATE class `22` or `23`) the run abandons itself — drops the shadow, releases the fence or slot, deletes the checkpoint row — and the verdict is `failed` with `code: data-fault`, so correct the data or the statement and run it again from the beginning; when the shadow and source diverge at verification (or the abandon itself failed), the row and the hold stay and the run has to be started over: correct the data or the statement, then run it with `--restart SCHEMA.TABLE` (`CopySwapOptions.Restart` as a library caller), which drops the shadow, releases the fence or slot, deletes the checkpoint row, and runs the change fresh under the run's own lock ([stopping, resuming, and starting over](schemabot-integration.md#stopping-resuming-and-starting-over)). |
| `ALTER COLUMN TYPE ... USING <expr>` the copy cannot reproduce | The copy-and-swap route (above) runs the retype, but the shadow builder admits a `USING` expression only when it can evaluate it again, identically, in the chunk copy, the verifier's recopy, and every change the catch-up flushes. Three rules, each refused as `shadow-statement-conversion` before any shadow is committed: the expression must be **immutable** by the server's own classification (no `now()`, `random()`, `current_setting()`, session-dependent casts such as `text` to `timestamptz`, or functions declared `STABLE` or `VOLATILE`; checked by trying it as a stored generated column's expression on the empty shadow inside the build's transaction, then rolling that attempt back), it must **name only the column it retypes**, bare and unqualified (no neighbouring column, no `t.col`, no whole-row `t.*`), and it must not retype the **primary key**, whose values every chunk range, watermark, and resume clear are expressed in. The expression is also refused when the deparser cannot prove it rendered the text faithfully. Rewrite the expression in terms of the retyped column alone and of `IMMUTABLE` functions, or retype without `USING` so the server's assignment cast converts the value; the refusal names the column, the expression, and that rewrite. The spellings most often refused, and what to write instead, are in [`USING` expressions the copy reproduces](#using-expressions-the-copy-reproduces) below. |
| A schema change to the source table while a copy-and-swap runs | Unsupported: logical decoding does not stream DDL, and the copy does not lock it out — a chunk holds `ACCESS SHARE` on the source only for its own transaction, so `ALTER TABLE` on the source completes between chunks. The copy selects the source by column name, so what happens depends on whether every copied name still resolves. A copied column renamed or dropped stops the copy at its next chunk with the server's `undefined_column` error (SQLSTATE `42703`), which is not a typed refusal; that chunk stays in flight and nothing from it reaches the shadow. A change that leaves every copied name resolving to a column of a compatible type — two columns swapping names, a column renamed and re-added under the old name, a widened type — is not detected by the copy, which completes, and after a swap or a re-add the rows copied since hold another column's values or the new column's default. Once the replication stream has decoded a change to the table, a later relation description that differs stops it with `ErrSourceShapeChanged`. None of these reaches the swap: the verifier compares source and shadow rows by column name, and the cutover gate re-fingerprints the source and refuses any changed column as `cutover-schema-drift` ([refusal-classes.md](refusal-classes.md#shadow-operation-refusals-keyed-on-refusalcause)), after which the shadow is dropped and the change re-planned. |
| A concurrent index build whose name is occupied by an invalid index, or whose table carries quarantined debris | The build refuses before running and never drops the occupant: an invalid entry is either abandoned debris or someone's build still in progress, and a drop by name cannot tell them apart. The refusal is typed by proof (`invalid-index-abandoned`, `invalid-index-build-in-flight`, `invalid-index-other-table`, `invalid-index-not-droppable`, `invalid-index-builder-unobservable`), and a table carrying an entry a previous recovery quarantined (`pgsprite_abandoned_<oid>`) but did not drop is refused the same way, so debris never goes quiet. The proven removal — `executor.RebuildAbandonedIndex`, or `executor.DropAbandonedIndex` to remove the leftover without running the build; both library-only today, no CLI verb — takes a bounded `SHARE UPDATE EXCLUSIVE` lock on the table (which any running concurrent index command holds), re-verifies the entry by OID, renames it to an identity-derived name and drops that, all within one shared budget; it refuses a visible in-flight build, another table's entry, or an entry the server will not drop concurrently (a partitioned table's index, an index partition, a constraint's index), and reports a lock budget rather than wait behind a build it cannot see. From the CLI, the [runbook](invalid-index-recovery.md) applies. |
Expand Down Expand Up @@ -122,7 +122,7 @@ composition of the model boundaries above with those gates. At a glance:
| Add a constraint (`UNIQUE`, `CHECK`); `SET NOT NULL` | Converges via the safer online sequence; not size-guarded either. |
| Relax a `NOT NULL` | Refused, whole plan: dropping `NOT NULL` discards the same guarantee its constraint form would, so it is destructive. Run it deliberately through the imperative front door — it executes natively there once `--allow-destructive` names the table — then rerun. |
| Change an index or constraint definition | Refused, whole plan — the drop-and-recreate row above. |
| Narrow a column type (`varchar(255)` → `varchar(50)`) | Converges through copy-and-swap: the plan's disposition is `execute` with the statement routed to the `copy-and-swap` backend, and desired-state execution runs it with the same preflight gates and flags as `migrate --alter` (the Copy-and-swap row above). The route's preflight is read at admission, before any statement runs, so a table or server the route refuses — no `--free-disk`, no logical decoding without `--quiesced`, a trigger, a dependent view — refuses the whole plan with nothing executed, native statements included. The dry run and `diff` do not read the preflight — the plan report says `execute` on the route alone, and whether the route can run here, now, with these flags, is answered when the plan is executed. A plan that retypes two columns runs two copy-and-swap passes on the table, one per statement; the second replaces the done row the first leaves behind. |
| Narrow a column type (`varchar(255)` → `varchar(50)`) | Converges through copy-and-swap: the plan's disposition is `execute` with the statement routed to the `copy-and-swap` backend, and desired-state execution runs it with the same preflight gates and flags as `migrate --alter` (the Copy-and-swap row above). The route's preflight is read at admission, before any statement runs, so a table or server the route refuses — no `--free-disk`, no logical decoding without `--block-writes SCHEMA.TABLE`, a trigger, a dependent view — refuses the whole plan with nothing executed, native statements included. The dry run and `diff` do not read the preflight — the plan report says `execute` on the route alone, and whether the route can run here, now, with these flags, is answered when the plan is executed. A plan that retypes two columns runs two copy-and-swap passes on the table, one per statement; the second replaces the done row the first leaves behind. |
| A bounded-attempt edit on a table above the size threshold | Refused at that statement (`not-native-safe-table-too-large`): with the default 1 GiB threshold, adding a column to a larger table refuses until the threshold is raised. |

A destructive refusal is all-or-nothing: one destructive statement refuses
Expand Down
Loading
Loading