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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ refusal — never a silently wrong or incomplete result:
`--allow-destructive SCHEMA.TABLE` names the table; the acknowledged run is
audited and its verdict carries `allowed_destructive: true`. A drop that
adds the same column back gets the guidance to change its type in place,
which keeps its values.
which keeps its values. A `DROP INDEX` run under `--accept-blocking` needs
`--allow-destructive` too: accepting the lock is not accepting the loss,
and the refusal lists the checks (`idx_scan` on every replica, foreign
keys, uniqueness) that catch an index the application still uses.
- **Destructive desired-state plans are refused as a whole** — `migrate --desired`
creates missing tables and converges supported changes, but never infers permission
to discard live structure. A new primary key or unique constraint over a live
Expand All @@ -150,7 +153,8 @@ refusal — never a silently wrong or incomplete result:
`migrate --accept-blocking SCHEMA.TABLE` runs an otherwise-refused plain
`DROP INDEX` (not `CASCADE`), `REINDEX INDEX`, or `REINDEX TABLE`
deliberately under the engine's bounded lock and statement budgets, with
the verdict marked `executed-without-online-safety` (exit 3). Every other
the verdict marked `executed-without-online-safety` (exit 3). A `DROP INDEX`
also needs `--allow-destructive` naming the same table. Every other
refusal — including a blocking index build on a partitioned parent — still
exits 2. Design:
[docs/lock-budgeted-passthrough.md](docs/lock-budgeted-passthrough.md).
Expand Down
47 changes: 36 additions & 11 deletions demo/tour.sh
Original file line number Diff line number Diff line change
Expand Up @@ -203,31 +203,55 @@ execute_refused() {
fi
}

# execute_accepted table sql
# execute_accepted table allowed_destructive sql
#
# Runs a gate-refused blocking index statement under the --accept-blocking
# acknowledgement and requires the without-online-safety verdict: its own
# acknowledgement plus --allow-destructive (which a DROP INDEX needs and a
# REINDEX ignores), and requires the without-online-safety verdict: its own
# exit code 3 (never 0, which stays exclusive to online-safe execution),
# the passthrough marker, and the table the acknowledgement named. The
# statement budget is spelled on the command line because the flag refuses
# to inherit the default as the accepted outage bound.
# the passthrough marker, whether the verdict records an acknowledged loss,
# and the table the acknowledgements named. The statement budget is spelled
# on the command line because the flag refuses to inherit the default as
# the accepted outage bound.
execute_accepted() {
local table="$1" sql="$2" out status=0
step "execute (accept blocking on $table): $sql"
local table="$1" allowed="$2" sql="$3" out status=0
step "execute (accept blocking and the loss on $table): $sql"
if [ "$CHECK" = 1 ]; then
out=$("$PGS" migrate --url "$PG_DSN" --json --alter "$sql" \
--accept-blocking "$table" --statement-timeout 30s) || status=$?
--accept-blocking "$table" --allow-destructive "$table" --statement-timeout 30s) || status=$?
assert_eq "accepted exit of [$sql]" 3 "$status"
assert_eq "outcome of [$sql]" "executed-without-online-safety" "$(jq -r '.outcome' <<<"$out")"
assert_eq "blocking_passthrough of [$sql]" true "$(jq -r '.blocking_passthrough' <<<"$out")"
assert_eq "allowed_destructive of [$sql]" "$allowed" "$(jq -r '.allowed_destructive // false' <<<"$out")"
assert_eq "table of [$sql]" "$table" "$(jq -r '.table' <<<"$out")"
assert_eq "statement_timeout of [$sql]" 30s "$(jq -r '.statement_timeout' <<<"$out")"
else
"$PGS" migrate --url "$PG_DSN" --alter "$sql" --accept-blocking "$table" --statement-timeout 30s \
"$PGS" migrate --url "$PG_DSN" --alter "$sql" --accept-blocking "$table" --allow-destructive "$table" \
--statement-timeout 30s \
|| echo "(exit $? — expected 3: committed without an online-safety guarantee)"
fi
}

# execute_accepted_needs_loss table sql
#
# The same DROP INDEX under --accept-blocking alone: accepting the lock is
# not accepting the loss, so it is refused (exit 2, destructive-change) and
# nothing runs.
execute_accepted_needs_loss() {
local table="$1" sql="$2" out status=0
step "execute (accept blocking only on $table): $sql"
if [ "$CHECK" = 1 ]; then
out=$("$PGS" migrate --url "$PG_DSN" --json --alter "$sql" \
--accept-blocking "$table" --statement-timeout 30s) || status=$?
assert_eq "refusal exit of [$sql]" 2 "$status"
assert_eq "reason of [$sql]" "destructive-change" "$(jq -r '.reason' <<<"$out")"
assert_eq "table of [$sql]" "$table" "$(jq -r '.table' <<<"$out")"
else
"$PGS" migrate --url "$PG_DSN" --alter "$sql" --accept-blocking "$table" --statement-timeout 30s \
|| echo "(refused, exit $? — expected 2: a dropped index also needs --allow-destructive)"
fi
}

# execute_accept_mismatch wrong_table sql
#
# The acknowledgement names the table the statement locks; naming another
Expand Down Expand Up @@ -486,8 +510,9 @@ run_exec() {
# runs first so the index is still there to drop afterwards.
execute_refused index-statement "DROP INDEX orders_total_idx"
execute_accept_mismatch public.users "DROP INDEX orders_total_idx"
execute_accepted public.orders "DROP INDEX orders_total_idx"
execute_accepted public.orders "REINDEX TABLE orders"
execute_accepted_needs_loss public.orders "DROP INDEX orders_total_idx"
execute_accepted public.orders true "DROP INDEX orders_total_idx"
execute_accepted public.orders false "REINDEX TABLE orders"
# A drop discards the column's values, so it is refused until the
# acknowledgement names the table, then runs natively.
refused_destructive "ALTER TABLE users DROP COLUMN status"
Expand Down
9 changes: 6 additions & 3 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ them is a limitation:
silently run the blocking form. Index maintenance (`DROP INDEX`, `REINDEX`) is the
second case: the online form is a plain `CONCURRENTLY` statement, which `migrate`
refuses to wrap and names so you run it directly; for a single-relation plain form,
`--accept-blocking` runs it under budgets and exits 3.
`--accept-blocking` runs it under budgets and exits 3 (a `DROP INDEX` also needs
`--allow-destructive`).

Every matrix table carries an **"Online-safety problem?"** column: "Yes" means there is a
readers-and-writers problem for an online engine to solve (pg-sprite solves it, plans to,
Expand Down Expand Up @@ -240,7 +241,7 @@ or the only online form is a direct statement the operator runs).
| Operation | Status | Engine path | Online-safety problem? | Behavior and why |
| --- | --- | --- | --- | --- |
| `CREATE [UNIQUE] INDEX` on a plain table — including partial, expression, covering (`INCLUDE`), GIN/GiST/BRIN | ✅ | native, safer sequence | Yes | Executed as (or rewritten to) `CREATE INDEX CONCURRENTLY`, with validity verification, typed invalid-index outcomes, and a proven recovery for abandoned leftovers (`RebuildAbandonedIndex`, or `DropAbandonedIndex` to remove the leftover without rebuilding; both library-only; [runbook](invalid-index-recovery.md)) |
| `DROP INDEX` | ❌ | — | Yes — the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive | `migrate` never runs this statement online-safely: the plain form is refused and names `DROP INDEX CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A single-index plain `DROP INDEX` without `CASCADE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0); `--accept-blocking` checks the replica identity under the table's lock and refuses the drop as `unsupported-statement`, naming the identity index. `diff` reports an index to remove as a **destructive** `DROP INDEX` and hints to run it through `migrate --alter`, which ends in this `index-statement` refusal — run `DROP INDEX CONCURRENTLY` directly instead. `diff` refuses dropping the table's replica identity index, or one a partition's is attached under, with `replica-identity`. `plan` and `lint` refuse `DROP INDEX … CASCADE` with `cascading-drop`, since `DROP INDEX CONCURRENTLY` does not accept `CASCADE` |
| `DROP INDEX` | ❌ | — | Yes — the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive | `migrate` never runs this statement online-safely: the plain form is refused and names `DROP INDEX CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A single-index plain `DROP INDEX` without `CASCADE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` plus `--allow-destructive SCHEMA.TABLE` for the loss (exit 3, never 0; without the second acknowledgement it is refused with `destructive-change`); `--accept-blocking` checks the replica identity under the table's lock and refuses the drop as `unsupported-statement`, naming the identity index. `diff` reports an index to remove as a **destructive** `DROP INDEX` and hints to run it through `migrate --alter`, which ends in this `index-statement` refusal — run `DROP INDEX CONCURRENTLY` directly instead. `diff` refuses dropping the table's replica identity index, or one a partition's is attached under, with `replica-identity`. `plan` and `lint` refuse `DROP INDEX … CASCADE` with `cascading-drop`, since `DROP INDEX CONCURRENTLY` does not accept `CASCADE` |
| `REINDEX` | ❌ | — | Yes — the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive | `migrate` never runs this statement online-safely: the plain form is refused and names `REINDEX ... CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A plain `REINDEX INDEX` or `REINDEX TABLE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `REINDEX SCHEMA`, `DATABASE`, and `SYSTEM` stay refused. A desired schema cannot express a reindex, so `diff` never produces one |
| `ALTER INDEX ... RENAME TO` | ✅ | native, as-is | Yes | Metadata-only: application SQL names tables and columns, not indexes. `migrate` resolves the index's owning table from the catalog, preflights and privilege-checks that table, and runs the rename under the brief budgets after locking the table in `SHARE UPDATE EXCLUSIVE` and re-checking that the name is still a plain index of it, then confirms before the commit that the index now carrying the new name is the one it checked, since the rename locks only the index and another session can move the name meanwhile (else the permanent `renamed-index-drift` code, nothing renamed — `ALTER INDEX ... RENAME` would also rename a table). An index that backs a primary-key, unique, or exclusion constraint renames the constraint, which `ON CONFLICT ON CONSTRAINT` names, so it is refused as `rewrite-required` (`app-breaking-rename`, guidance `coordinated-constraint-rename`); `--force` does not apply to an index rename. Offline `lint` cannot tell the two kinds of index apart and reports neither. A desired file cannot express a rename |
| Index build on a **partitioned parent** | 🟡 | native, planned flow | Yes | PostgreSQL has no parent-level `CONCURRENTLY`; the blocking form is refused by policy (`--force` does not bypass it, and `--accept-blocking` does not reach it yet). The partition-aware flow — `CREATE INDEX ON ONLY` → per-partition CIC → `ATTACH PARTITION`, with crash-resume per leaf — is planned |
Expand Down Expand Up @@ -371,7 +372,9 @@ unmistakably marked as executed without an online-safety guarantee
resolved from the catalog, and `--statement-timeout` must be spelled on the same command
line. Only changes the engine understands but cannot run *safely* are eligible: today a
single-relation plain `DROP INDEX` (never `CASCADE`, which also drops dependent objects such
as a foreign key on another table), `REINDEX INDEX`, or `REINDEX TABLE`. Refusals for
as a foreign key on another table), `REINDEX INDEX`, or `REINDEX TABLE`. A `DROP INDEX`
also discards the index, so it needs `--allow-destructive` naming the same table; without
it the drop is refused with `destructive-change` before anything runs. Refusals for
unrecognized SQL never are, and the refused statement's `blocking_passthrough_eligible`
field on the plan report says whether a refusal qualifies. An exhausted lock budget is
still a refusal (exit 2, nothing ran); a statement cancelled by its budget is a failure
Expand Down
9 changes: 7 additions & 2 deletions docs/cli-output-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,10 @@ An operator-accepted blocking refusal has a distinct marked outcome and exit
takes the schema-qualified table whose lock the operator accepts, must match
the index's owning table exactly (a mismatch executes nothing and exits 1),
and requires an explicit `--statement-timeout`; it cannot be combined with
`--force` or `--dry-run`. The text rendering marks the outcome as a warning:
`--force` or `--dry-run`. A `DROP INDEX` also discards the index, so it needs
`--allow-destructive` naming the same table; without it the drop is refused
with `destructive-change` (exit 2) and nothing runs. The text rendering marks
the outcome as a warning:

```text
executed without online safety (accepted blocking refusal)
Expand All @@ -273,17 +276,19 @@ executed without online safety (accepted blocking refusal)
statement: DROP INDEX public.users_email_idx
safer: DROP INDEX CONCURRENTLY
budgets: lock 3s, statement 10m
allowed: the statement's data loss was acknowledged (allow-destructive)
```

```console
$ pg-sprite migrate --alter 'DROP INDEX public.users_email_idx' --accept-blocking public.users --lock-timeout 3s --statement-timeout 10m --json
$ pg-sprite migrate --alter 'DROP INDEX public.users_email_idx' --accept-blocking public.users --allow-destructive public.users --lock-timeout 3s --statement-timeout 10m --json
{
"outcome": "executed-without-online-safety",
"reason": "index-statement",
"class": "by-design",
"statement": "DROP INDEX public.users_email_idx",
"table": "public.users",
"safer_idiom": "DROP INDEX CONCURRENTLY",
"allowed_destructive": true,
"blocking_passthrough": true,
"lock_timeout": "3s",
"statement_timeout": "10m"
Expand Down
13 changes: 9 additions & 4 deletions docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,8 @@ would without the flag — including a `DROP INDEX ... CASCADE`, whose refusal s
ineligible one because its scope exceeds the acknowledged table — and a statement the gate
admits runs its normal path. The
acknowledgement is not `--force`: the two cannot be combined, and a dry run reports the
refusal as it stands rather than the accepted execution. The front door requires the
refusal as it stands rather than the accepted execution. It accepts the lock only: an
accepted `DROP INDEX` also needs the destructive acknowledgement (RF-10). The front door requires the
statement budget to be stated on the same command line, because the accepted outage bound is
a decision, not a default. *Enforced:* `migrate.AcceptedRefusal`, `MigrateCmd.Validate`,
`migrate.gateRefusal` (the refusal site), `TestAcceptedRefusal`, `TestAcceptBlockingFlagGrammar`,
Expand Down Expand Up @@ -1400,8 +1401,11 @@ Each refusal is a preflight **error with a stated reason** — never a warning,
other gate, and an acknowledged run is audited at warn level and carries
`allowed_destructive: true` on its verdict. The declarative path takes no acknowledgement
and refuses a destructive plan whole. A planner refusal runs nothing and keeps its own
reason. A `DROP INDEX` stays with the statement-kind gate and `--accept-blocking`, which
the destructive acknowledgement does not reach. Outage-risk statements are covered by
reason. A `DROP INDEX` — unique or not — is gate-refused first; when `--accept-blocking`
accepts that refusal, the drop still runs only if `--allow-destructive` names the same
owning table, and otherwise it is refused with `destructive-change` before anything
executes, the detail listing the checks that catch an index the application still uses.
Accepting the lock is not accepting the loss. Outage-risk statements are covered by
RF-5 and AB-1..AB-5 (`--force`, `--accept-blocking`) and by the LK-2 budgets.
*Enforced:* `pkg/migrate` (`run`'s destructive gate through `destructiveOperations` and
`destructiveChangeVerdict`, `checkAllowDestructiveAck`, `AdmitDestructive` for the dry
Expand All @@ -1412,7 +1416,8 @@ Each refusal is a preflight **error with a stated reason** — never a warning,
`TestRunRejectsAMismatchedDestructiveAcknowledgement`,
`TestRunRefusesAnUnacknowledgedDropColumnRoutedToCopyAndSwap`,
`TestRunNeedsBothForceAndTheDestructiveAcknowledgement`,
`TestRunAllowDestructiveDoesNotLiftADropIndexRefusal`, `TestAdmitDestructive`,
`TestRunAllowDestructiveDoesNotLiftADropIndexRefusal`,
`TestRunAcceptBlockingNeedsTheDestructiveAcknowledgementToDropAnIndex`, `TestAdmitDestructive`,
`TestMigrateDryRunReportsTheDestructiveRefusal`, `TestAllowDestructiveFlagGrammar`,
`demo/tour.sh` (`refused_destructive`). *Source:*
[design principles](design-principles.md#declarative-review-first-workflow)
Expand Down
Loading
Loading