schemadiff: plan a rebuild for an index left invalid by an unfinished build - #86
Conversation
… build A live index with the desired name and definition but indisvalid = false diffed to no change, so a crashed concurrent build settled as delivered. The model now carries validity and the diff emits the create alone — never a drop, which would block the table and cannot tell debris from a build in progress; the concurrent build path proves the occupant before removing it.
…th sides An invalid live index is either abandoned debris or a build still running, and the diff cannot tell them apart; the drop loop still emitted the blocking DROP INDEX for one desired removed or redefined, and a REINDEX CONCURRENTLY leftover wedged the table's whole plan. The diff now never drops an invalid entry on a plain table, and ignores validity on a partitioned parent, where it means unattached partition indexes rather than an unfinished build. Validity is modelled fail-safe (Invalid), the integration tests execute the derived plan through RebuildAbandonedIndex and pin indisvalid against indisready with a parked in-flight build, and the pull round-trip contract states the one-create exception.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Adversarial correctness review (1 of 2) — Reading validity into the model is the right fix and the column choice is the crux of it. Mutation testing found no survivors, which is rare here: The last one is the important line. Nothing in the unit layer can tell the two columns apart; it dies only in One finding.
The removed-invalid case reports convergence
For the in-flight builder that is unarguably right — you must not drop an index from under a running build, and a plain The awkwardness is that
Reporting is the cheap one and it is the one I would do first: the current behavior is defensible as a policy, but only if a caller can find out it happened. What holdsThe parked-build test is the strongest thing in the change. It builds the The The partitioned-parent carve-out is correct and tested in both directions — a matching invalid parent index is not rebuilt (the server never builds a partitioned index concurrently, so invalid means unattached partitions), and one desired drops is still dropped. Dropping the guard fails both halves. The round-trip exception is proved, not asserted. This review was generated by Claude Code (claude-opus-5). |
|
🤖 Adversarial correctness review (2 of 2) — invariants, the consent surface, and one shape with no integration coverage. InvariantsOC-2 is the entry this change is really about, and it lands on both halves of it. OC-2 says a started build must not be silently marked succeeded and must not be cleaned up by a later change of intent. The named case is the first half, and fixing it is the whole point: a live entry with the desired name and definition that never finished building was diffing to nothing, so a re-plan after a crash reported the change delivered. That is exactly "silently marked succeeded", and the fix is right. Suppressing the drop is the second half, and also right — an in-flight build is started work and a Which is why the removed case is worth stating in the summary rather than left implicit: for a table whose desired file no longer names the entry, the change fixes the cleanup half and re-introduces the succeeded half, since an empty plan is how this tool says "delivered". Naming that as a deliberate, documented trade — and citing the invariant it sits under — is more reviewable than the current framing of "not the diff's to remove", which answers the cleanup question only. OC-1 reaches the same place from the other side (in-flight ambiguity must never become a passing status); OC-2 is the sharper citation because it already contemplates a reverted desired file. LK-5 is cited correctly and needs no text change. The proof, the OID-derived quarantine name, the pre-/post-drop OID checks and the droppability predicate all still live in ST-8 is untouched — the create still rides the desired schema's proven order, and The plan says non-destructive and the execution drops a relationFor the matching and redefined shapes the emitted change is a single The gap is the operator's view. A caller gating on Related, and worth aligning because two audiences read two documents: the PR body says "the concurrent build path — which proves the occupant by identity before removing it ( The redefined shape converges, and nothing proves itBoth integration tests cover the matching shape — real debris, and an in-flight build. The redefined shape (desired changes the definition of a name whose live entry is invalid) has only the unit test, which asserts the plan is create-only. That leaves the interesting half unasserted: the recovery proves the occupant by name, table, validity and droppability, not by definition, so a redefinition over a leftover should converge on the create alone. Should — there was no test either way. I checked rather than assuming, and it does converge. A failed // desired asks for a plain index under the name the failed UNIQUE build left invalid
require.NotEqual(t, live.Indexes[0].Def, want.Indexes[0].Def, "desired redefines the leftover")
changes, err := schemadiff.Diff(schema, live, want)
require.NoError(t, err)
require.Len(t, changes, 1, "create only, no drop")
concurrent, err := statement.Concurrently(changes[0].SQL)
require.NoError(t, err)
rep, err := executor.RebuildAbandonedIndex(t.Context(), pool, concurrent,
executor.ConcurrentBudget{CallerOwned: true})
require.NoError(t, err, "the recovery must converge a redefinition over the leftover")
require.Len(t, rep.Dropped, 1)DocsThorough, and the two
This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving — reading indisvalid into the model is the right fix, and the parked-build integration test proves the column choice against a real indisready = true, indisvalid = false window rather than a fixture. Five mutations, no survivors.
One medium in the review comments: a table carrying an invalid index the desired file no longer names now diffs to zero changes, where before it produced a visible (destructive-gated) drop — so the validity observation is read, used, and discarded. Not dropping it is right; reporting the table as converged is the separable half, and OC-2 is the entry that covers both. Also worth adding: the redefined-invalid shape has no integration coverage — I verified it does converge through the recovery, so it is a missing test rather than a bug.
This stamp was left by Claude Code (claude-opus-5).
…e the redefined-invalid case converges The index helpers took the whole live Model only to read PartitionKey. Diff now computes partitioned once and passes it, so the one reason a partitioned parent's invalid entries are not unfinished builds sits at the call site next to its explanation, and the helpers read as the predicates they are. Add an integration test over the same failed-unique-build debris where the desired file redefines the index as plain: the diff plans the create alone, never a drop, and RebuildAbandonedIndex converges it without touching the duplicate rows, since the recovery proves the occupant by name and validity, not by definition. Docs: record under OC-2 that the never-drop rule keeps the cleanup half of the invariant and gives up the silently-converged half for a removed invalid index (empty plan), and under LK-5 that the diff now emits a create it knows cannot run as-is and depends on the proof for the plan to complete. pull.md states that a fresh baseline re-diffing to a lone create-index is itself the signal to check pg_index.indisvalid. capabilities.md splits the plain-table row's rebuild and never-drop sentences.
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude) — pull/86, follow-up commit All findings from both comments are answered: seven fixed in
The "What holds" section of part 1 and the ST-8 / docs confirmations in part 2 need no action. Source: #86, review comments 5576879982 and 5576880610 at head |
schemadiffnow readspg_index.indisvalidinto the index model and plans a live invalid index that matches the desired definition as acreate-indexchange instead of "no change".Why
A concurrent index build that dies after its catalog entry commits — a killed builder, a crashed orchestrator, a lost connection — leaves an index that carries the desired name and definition but is invalid: the planner never uses it and it serves no query. The diff compared indexes by name and definition only, so that leftover diffed to nothing and a re-planned convergence reported the table as already matching the desired state. An orchestrator that re-plans after a crash then settles the change as delivered while the index is invalid — a silent wrong success. The diff has to see validity to plan the rebuild.
What
schemadiff.IndexgainsInvalid(fromi.indisvalid; the zero value is the delivered state). The desired side, materialized on the scratch schema, is never invalid.Diffemits the create for a live index that is absent, redefined, or invalid (indexNeedsCreate), and never emits a drop for an invalid entry whatever the desired file says about its name (indexNeedsDrop): a plainDROP INDEXblocks the table and cannot distinguish abandoned debris from a build in flight. AREINDEX … CONCURRENTLY_ccnewleftover therefore no longer wedges the table's plan behind the destructive gate.CREATE INDEXfails as a duplicate relation (42P07); the concurrent build path (BuildIndexConcurrently) refuses by proof with a typedfailedoutcome (invalid-index-abandonedfor debris,invalid-index-build-in-flightfor a running build) and never drops the occupant. Nothing routes from that refusal to a removal: the caller chooses — a library caller runs the same create throughexecutor.RebuildAbandonedIndex, which proves the occupant abandoned by identity under the lock that excludes its builder (LK-5) and then builds; a CLI operator follows the invalid-index runbook.docs/limitations.mdstates the same.indisvalid = falsemeans unattached partition indexes, not an unfinished build, so validity plays no part there;Diffcomputespartitionedonce and passes it to the index helpers, so the carve-out sits at the call site. The parent's indexes compare by name and definition as before.42P07, executes the plan'sCONCURRENTLYform throughRebuildAbandonedIndex, and asserts convergence; a second integration test over the same debris where the desired file redefines the index (plain where the leftover was unique), proving the create-only plan converges through the same path without touching the duplicate rows; and an integration test that parks a caller-owned build atindisready = true, indisvalid = falsebehind a repeatable-read snapshot and asserts create-only / no-drop, which pinsindisvalidagainstindisready.docs/capabilities.md,docs/limitations.md(two desired-file-edit rows),docs/pull.mdand theRendercontract state the one round-trip exception: a table carrying an invalid index re-diffs to exactly its rebuild, and a freshly pulled baseline that re-diffs to a lonecreate-indexis the signal to checkpg_index.indisvalid.docs/invariants.mdrecords the trade under OC-2 — the never-drop rule keeps the cleanup half of the invariant and gives up the "silently converged" half for a removed invalid index (empty plan) — and notes under LK-5 that the diff now emits a create it knows cannot run as-is and depends on LK-5's proof for the plan to complete. Thedemo/tour.shassertions are unaffected.Known gap
A desired file that removes an index whose live entry is invalid diffs to an empty plan: the diff will not drop it and has no other change to hang the observation on, so the leftover is invisible in
diff --json. Likewise aDestructive: falsecreate-indexwhose execution throughRebuildAbandonedIndexrenames and drops a relation carries no hint of that in the plan. A plan-level non-fatal advisory alongside[]Changeis the fix; it adds a field to the plan JSON contract and is tracked separately rather than folded in here.Before / after
Same starting state in both: a live index with the desired name and definition, left invalid by an unfinished build. What changes is which columns the diff compares, and where the leftover is dealt with.