feat(executor): prove and recover abandoned invalid indexes - #79
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Adversarial correctness review — The load-bearing claim here is the one in the file header — that I also chased the one step that looked like it could block inside the proof transaction and found it can't:
So the findings are narrow. One is a real behavioural gap, one is where the suite stops pinning the proof, and one is about the consumer this whole file exists for.
1 — the target's identity is proven by OID, but never against the name the statement gave (low-med)// LOCK TABLE takes a name, not an identity: a table renamed or dropped
// since the resolution is no longer the table the statement names, ...
table := pgx.Identifier{target.schema, existing.table}The comment states the rule; the line below it doesn't implement it for one window. Measured, on the internal fixture — resolve, rename, inspect, prove: The rename went through on a table the statement never names. Downstream the sweep then drops that entry, and step 3's Worth being precise about the blast radius, because it is smaller than the shape suggests: the entry removed is genuinely invalid debris on the same physical relation (same OID), so nothing valuable is destroyed, and LK-5 as written is not violated — it says "on the target table by OID and schema", and both hold. What's violated is the narrower promise in this comment, and the window is two adjacent catalog round-trips on one connection, so this is a race no operator arranges deliberately. It's also a one-line close, which is most of why I'd take it: Two adjacent things I checked that are fine and shouldn't be changed: locking 2 — where the suite stops pinning the proof (low)Ten mutations against the safety predicates; four killed, three survived reachably (three more survived but are unreachable by construction — detail in the second comment).
The last one is the one worth acting on, because a test already carries its name: t.Run("table moved to another schema", func(t *testing.T) {
f.exec(t, "ALTER TABLE %s.t SET SCHEMA %s", f.schema, other)
...
require.ErrorIs(t, err, ErrTargetIdentityChanged)That passes without the check, because (Note the consequence there is a typed-outcome regression, not a wrong drop — the rename still fails loudly, just as an opaque "could not be proven" instead of The other two are plainer gaps. 3 — the routing table is right; the consumer is on the wrong side of it (low)This isn't a defect in the diff —
What I'd add is where that lands, since it's concrete and the PR body frames the consumer work as "must be updated" without saying that the mechanical version of the update is wrong. In case executor.CodeBudgetLockExceeded, executor.CodeCancelledExternally,
executor.CodeInvalidIndexOwnLeftover, executor.CodeInvalidIndexPreexisting,
executor.CodeInvalidIndexUnproven, executor.CodePoolTooSmall,
executor.CodeExecutionFailed:
// Operational outcomes: ... A retry can succeed once
// conditions change, so none is a permanent refusal.
return nil, truePasting the five successors where the one used to sit compiles, passes the exhaustiveness test at Two cheap things would make the doc's warning self-enforcing rather than advisory:
This review was generated by Claude Code (claude-opus-5). |
|
🤖 Verified — the runs, the mutations, and five attacks that dissolved Local. The lock claim, checked at the source rather than the docs. The file header rests on "every concurrent index command holds SHARE UPDATE EXCLUSIVE on the table for its whole life." That is true for a non-obvious reason worth having in the record: Mutation testing — ten mutations,
The last three are worth separating from the other survivors rather than counted against the diff. Each is a post-condition on a statement the server has already reported successful, and there is no way to make PostgreSQL report a successful Attack that dissolved: a Attack that dissolved: the Attack that dissolved: the progress view's hidden-row accounting. Attack that dissolved: Attack that dissolved: Leak check: clean. Nothing in the 19 files or the body names anything internal — no company names, hostnames, ticket ids or deployment topology. The Two notes, neither a finding. The branch is 1 behind Both probe files were moved out of the tree and every mutation restored from backup; the worktree is clean at This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. The abandonment proof is sound — I verified the session-level SUE lock claim at the source and the rename's lock level empirically, and four of the safety predicates kill their mutations. Findings are in the review comment above: one narrow rename window in the proof (low-med, one-line close), three reachable mutation survivors, and a note on the consumer's retryability grouping.
This stamp was left by Claude Code (claude-opus-5).
An invalid index left behind by a dead CREATE INDEX CONCURRENTLY previously blocked every retry forever: the executor refused and pointed at a manual DROP INDEX. RebuildAbandonedIndex now proves abandonment (SHARE UPDATE EXCLUSIVE table lock, which any live concurrent index command holds), quarantines the leftover by rename, drops it concurrently, and rebuilds. pg_stat_progress_create_index hides other roles' rows without pg_read_all_stats and records nothing under track_activities=off, so "no builder visible" is not "no builder". Classification reports that as invalid-index-builder-unobservable instead of abandoned; the lock proof, not the progress view, decides whether recovery proceeds.
…overy sweep Addresses the adversarial review: a partitioned parent's ON ONLY index is invalid by design and was quarantined then stranded; one undroppable quarantined entry wedged every later recovery on the table; the drop path had no per-lock bound and re-applied the full budget per entry; the recovery needed three connections but guarded for two; and none of the fail-closed re-verifications had a test that reached them.
8ec0ecc to
253e744
Compare
|
🤖 Re-review — The three-way cancellation typing is the right decomposition, and the part I'd have argued for is the part that's already here: a 57014 at or past the overall budget stays a One finding, in the same shape as the change itself: the taxonomy grew by one member, and one of the two places that route on it didn't.
1 — the recovery sweep can't report
|
|
🤖 Re-review, part 2 — carry-overs from round 1, and what the new code does downstream Still open from round 1
The three reachable mutation survivors from round 1 are also unchanged, since no recovery test moved: Downstream: the new code will stop the consumer's build, as designed
The routing I'd suggest: group it with the operational set alongside Two smaller notes on the same surface:
This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving at 253e7443. The three-way cancellation typing is the right decomposition and the budget-first precedence — a 57014 at or past the overall budget stays a *BudgetError even when the caller's context ended in the same instant — is the part that makes it usable by an orchestrator. CancelBuild replacing BuildPID() is a genuine safety improvement, not a rename.
One medium finding to pick up: isBoundedOutcome (recover.go:552) has no case for the new ErrCancelledByCaller, so the recovery sweep reports its own caller's cancellation as invalid-index-unproven — the fail-closed verdict — which contradicts dropQuarantinedIndex's own reasoning that a cancelled drop leaves the entry unchanged for the next sweep. Measured, one-line fix, suite green with it applied. Details plus two low findings in the comments above.
This stamp was left by Claude Code (claude-opus-5).
morgo
left a comment
There was a problem hiding this comment.
🤖 Automated review on Morgan's behalf. Approving.
This drops indexes automatically, so I read the proof chain rather than the description. It holds up, and the central design call is the right one: pg_stat_progress_create_index genuinely cannot answer "is anyone building this?" — it nulls relid/index_relid without pg_read_all_stats and records nothing under track_activities = off — so treating "no visible builder" as "no builder" would drop an index mid-build. Using the SHARE UPDATE EXCLUSIVE lock as the oracle is correct, because a concurrent index command holds it for its entire life, so holding it yourself excludes a builder you cannot see.
What I verified in the code:
- A valid index can never be quarantined or dropped.
isAbandonmentCandidaterequires!f.validalongside the exact OID, exact name,indrelid == target.tableOID, and droppability, and it gates both the under-lock quarantine and the pre-drop re-verify. - The sweep is self-keying, which is the property that makes automatic dropping safe.
listQuarantinedIndexesmatchesc.relname = ($2 || c.oid::text)— an entry qualifies only if its name is literallypgsprite_abandoned_followed by its own OID. An operator's index can't collide with that by accident, so the sweep can only ever see debris this code renamed. - Identity is re-established under the lock, not carried over.
LOCK TABLEnecessarily takes a name, and the re-check then requirestarget.tableOIDto still carry that name and schema — so a table renamed away with an impostor taking its name locks the impostor and immediately bails withErrTargetIdentityChangedrather than acting. Treating the schema as part of table identity is a subtle case to have gotten right. - The rename is verified before it commits: the proof's OID must now carry the quarantine name, or the
ALTER INDEXtouched something the proof never examined. - A reported drop is not trusted. Counting
pg_classfor the OID afterwards catches aDROP INDEX CONCURRENTLYthat failed midway and left the entry in place. - Both lock waits are bounded at 5s,
42P01and55P03are separated into distinct verdicts, andpgx.ErrNoRowsat the quarantine stage correctly no-ops rather than erroring. - Every identifier goes through
pgx.Identifier{...}.Sanitize(), quarantine names are derived from auint32, and the catalog predicates useOPERATOR(pg_catalog.=)so a hostilesearch_pathcan't shadow the operators.
Also worth crediting: skipping rather than refusing an undroppable quarantine entry, so one such entry can't wedge the table forever, and having a plain BuildIndexConcurrently refuse over droppable quarantine debris so a recovery that died between rename and drop can't go quiet.
Two observations, neither blocking:
- The drop path uses session-level
SET lock_timeoutrather thanSET LOCAL— unavoidable, sinceDROP INDEX CONCURRENTLYcan't run inside a transaction. The comment says the release resets it with the rest of the session settings, which is the right contract; just noting it's the one setting here whose cleanup depends on the pool release doing its job rather than on transaction scope. - I considered OID reuse — an index quarantined as
pgsprite_abandoned_12345, dropped externally, with a later index receiving OID 12345 and being named to match. It needs deliberate adversarial naming, andNOT indisvalidplus theindrelidand droppability predicates would still have to line up, so it isn't a practical concern. Mentioning it only so it's on the record as considered.
The consumer note about Codes() exhaustiveness and the removal of invalid-index-preexisting is the kind of thing that bites a downstream adapter silently, so it's good that it's called out in the description — worth carrying into the schemabot bump when it happens.
CI is clean and the PR is CLEAN to merge.
…ement's table The recovery sweep's bounded-outcome routing knew the budget and an operator's cancel but not the caller's own, so a sweep whose lease lapsed mid-drop wrapped that into a verdict on the entry and reported it as unproven. The entry a cancelled drop leaves is unchanged, not unproven; the sweep now reports cancelled-by-caller as itself. The abandonment proof locked the table the catalog inspection named and compared OID and schema, but never the table name the statement gave, so a rename between resolveTarget and the inspection was followed silently. Carry the statement's table on the target and check it before and under the lock. Code.Permanent() classifies each outcome code as decided-by-input (retry unchanged reproduces it, no executor entry point clears it) or not, so an adapter mapping the invalid-index family does not have to guess which of the five codes belong in a retry group. The execution-model table gains a Permanent column pinned to the method by a docs test. isStatementCancellation reads the server's 57014 and the client's context error each on their own, so another server error in the chain no longer hides a context cancellation. Tests pin the success-path verdict running detached from the caller's context, every cancellation cause through the sweep's routing, and the surviving mutations the review found: the schema half of the under-lock identity check, the sweep's visible-builder refusal on a quarantine-named entry, and droppableColumn's constraint-index term.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/79, follow-up commit All findings from review comments 5562518278, 5562518558 (round 2) and 5564254652, 5564255058 (round 3) are fixed in one follow-up commit; nothing deferred, nothing rejected. Every mutation the reviews named as surviving was reintroduced and confirmed to fail its new test. The two downstream notes in 5564255058 are answered inline; they are consumer-side work at the pin bump, not changes to this PR.
Verification on the follow-up: Adapter-side note (not a change to this PR): the consumer's Source: #79, review comments 5562518278 and 5562518558 at head |
…e-cause-vocabulary * origin/main: feat(executor): prove and recover abandoned invalid indexes (#79) # Conflicts: # pkg/executor/docs_test.go # pkg/executor/native.go
Adds
RebuildAbandonedIndex, a proven recovery path for invalid indexes left behind by a deadCREATE INDEX CONCURRENTLY, and fixes the classifier so an unobservable builder is never reported as abandoned.Why
When a concurrent index build dies mid-flight (executor crash, connection loss, statement cancel), PostgreSQL leaves the index in the catalog marked invalid. Until now the executor refused every retry of that statement and pointed the operator at a manual
DROP INDEX. That is the wrong end state for an engine that is meant to run unattended behind an orchestrator: the retry loop stalls on a leftover only pg-sprite created, and a human has to run destructive DDL by hand to unblock it.Recovery has to be proven, not assumed.
pg_stat_progress_create_indexis the natural "is anyone still building this?" oracle, but it nullsrelid/index_relidfor another role's command unless the reader haspg_read_all_stats, and it records nothing at all undertrack_activities = off. A classifier that reads "no visible builder" as "no builder" would drop an index that is actively being built. The definitive proof is the lock: every concurrent index command holdsSHARE UPDATE EXCLUSIVEon the table for its whole life, so acquiring that lock ourselves excludes any builder, visible or hidden. And "no builder" is still not "debris": a partitioned table'sON ONLYindex is invalid by design with nobody building it, so the proof also has to establish that the entry is one a failed concurrent build can leave and the server will drop concurrently.What
RebuildAbandonedIndex(ctx, pool, sql, budget)— inspect and classify → refuse unless the code is recoverable → bounded transaction (lock_timeout5s) takingLOCK TABLE … IN SHARE UPDATE EXCLUSIVE MODEas the abandonment proof → re-verify by OID (name, validity, table by OID, schema and the table name the statement gave, droppability, no visible builder) → quarantine viaALTER INDEX … RENAME TO pgsprite_abandoned_<oid>→ commit →DROP INDEX CONCURRENTLYeach quarantined entry on a budgeted session with a 5s per-lock bound and pre/post OID verification →buildIndexConcurrently. The proof lock, every drop, and the build share one overall budget. Returns anIndexRecoveryReport(Dropped,Skipped, whole-recoveryDuration, build report). A drop stopped by the budget, by an operator'spg_cancel_backend, or by the caller's own context is reported as that cause (budget-statement-exceeded,cancelled-externally,cancelled-by-caller) — never as a verdict on the entry, which a cancelled drop leaves exactly as quarantined for the next sweep. A table renamed, dropped, or moved since the observation — or one whose inspected name no longer matches the statement's — isErrTargetIdentityChanged; any other disagreement isErrAbandonmentUnproven. Needs a pool one connection larger than the build and refuses a smaller one withErrPoolTooSmall.relkind = 'i', notrelispartition, nopg_constraint.conindid). Order of proof strength: visible builder → other table → not droppable → unobservable → abandoned. New codesinvalid-index-builder-unobservable(recoverable — the lock proof decides) andinvalid-index-not-droppable(refused: a partitioned table's index, an index partition, or a constraint's index is never debris).invalid-index-preexistingis removed.catalogVerdictreadsindrelid, so another table's debris after a name collision is reported asinvalid-index-other-tablewith that table's name, not as this build's own leftover.BuildIndexConcurrentlyrefuses over droppable quarantine debris on the target table, so debris never goes quiet after a recovery that died between rename and drop.Code.Permanent()classifies each outcome code: permanent means the outcome is decided by the statement, the caller's configuration, or the standing catalog — retrying unchanged reproduces it and no executor entry point changes it. The execution-model table gains aPermanentcolumn that a docs test pins to the method.Permanent()classified for every code inCodes();droppableColumnchecked against the server's ownDROP INDEX CONCURRENTLYverdict (plain, primary-key, unique-constraint, exclusion, FK-referenced, partitioned parent, attached partition); internal tests on a real database that alter the catalog between the observation and the lock (table renamed before or after the inspection / moved schema behind a same-named decoy →ErrTargetIdentityChanged; index renamed / made valid in place →ErrAbandonmentUnproven; replaced by a concurrent reindex → nothing to do; the same for the pre-drop check) and a shared-budget arithmetic test; integration coverage for own-leftover removed and rebuilt, no debris, in-flight refusal, other-table refusal, lock budget at the proof and at the drop, quarantine sweep, sweep refusing a quarantine-named entry whose builder is visible, skipped undroppable entry, never dropping a valid index, partitioned parent refusal, plain build refusing quarantine debris, pool-size refusal, admission guards, hidden builder via a role withoutpg_read_all_stats, andtrack_activities = off; the sweep cancelled by its own caller while a drop waits on the table lock reportscancelled-by-callerwith the debris still quarantined; a caller-owned build that succeeds and is cancelled the instant its statement returns still reports a verified valid index; every cancellation cause routed through the sweep's bounded-outcome check;isStatementCancellationover chains that carry another server error beside the client's context error. The blocked-build helper registers at.Cleanupso a failed wait cannot leak a live build.invalid-index-recovery.mdrewritten around the seven-code table with the not-droppable state, the pool requirement, the lock bounds, the shared budget, and the report fields; new invariant LK-5 (an index is dropped only by proven identity, under the lock that excludes its builder) with// INV: LK-5tags at both re-verifications;schemabot-integration.mdgains a routing table for the invalid-index outcome codes and the opt-in recovery; execution-model, capabilities, limitations, tcb-model updated. Recovery is library-only for now (no CLI verb).Consumer note: an adapter that enumerates
executor.Codes()exhaustively must map the two new codes and no longer referenceinvalid-index-preexisting. The replacement codes are not one retry class: two of the five (invalid-index-other-table,invalid-index-not-droppable) are permanent and belong with the refusals, the other three wait on a recovery, a builder, or a re-taken proof —Code.Permanent()says which, so a total switch overCodes()on the consumer side needs that split rather than a mechanical paste of the removed code's arm. An adapter that retries invalid-index codes as transient errors will loop, because the refusal is deterministic untilRebuildAbandonedIndexruns.cancelled-by-calleris also new inCodes(): it is the caller's own context ending (a lease lapsing, a deadline expiring) and nothing about the target needs to change before a retry, so it belongs with the operational outcomes besidecancelled-externally, not with the refusals.Before / after
Authored with Amp (Claude Opus 4.5).