feat(engine): route concurrent index builds to the dedicated executor - #1219
Conversation
CREATE INDEX CONCURRENTLY cannot run in a transaction block, so it must never reach the transactional optimistic executor; pg-sprite's index-build executor runs it under a dedicated 4-minute budget (below the 5-minute apply ceiling so exhaustion surfaces as the typed budget verdict, not an external cancellation). Partition admission now also runs at apply time so a parent partitioned after planning gets the typed permanent refusal instead of a raw server error. A pre-existing invalid index fails the build as retryable with the operator action in the detail.
There was a problem hiding this comment.
Pull request overview
Routes PostgreSQL CREATE INDEX CONCURRENTLY statements away from the transactional optimistic executor (which PostgreSQL rejects) and adds apply-time partition admission so partitioning changes between plan/apply produce a typed refusal instead of a raw server error.
Changes:
- Route concurrent index builds to
executor.BuildIndexConcurrentlywith a dedicated 4-minute budget, while keeping non-concurrent statements onExecuteNative. - Run partition admission (
preflight.CheckPartitionSupport) at apply time for partitioned parents and classifyUnsupportedPartitionedParentErroras a permanent refusal. - Add unit + integration coverage for concurrent index build success, partitioned-parent refusal, and invalid-index retryable failure.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| pkg/engine/postgres/apply.go | Adds apply-time partition admission and dedicated concurrent index execution path; classifies invalid-index failures as retryable with sanitized operator-facing detail. |
| pkg/engine/postgres/apply_test.go | Extends refusal classification coverage to include partitioned-parent admission errors. |
| pkg/engine/postgres/postgres_integration_test.go | Adds integration tests for concurrent index build success, partitioned-parent refusal, and invalid-index retryable failure (one setup needs adjustment to avoid system catalog writes). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…failure The verdict wraps the build failure that produced it, so classification order let a nested statement-budget cause read as a permanent refusal that never named the invalid index the build left behind. The verdict now wins on both paths, and the operator advice follows the verdict code: only a proven own leftover names a drop; pre-existing and unproven states get investigation steps.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head Verdict: the routing and the classification ladder are right, and the ordering argument behind them is the good kind — the invalid-index arm being checked before the refusal and budget arms is subtle and correct. Nothing here blocks. My one finding is that the invariant those two time bounds depend on is asserted as arithmetic rather than as the property it protects, and the thing it protects is exactly the operator-facing detail this PR adds. Findings1. 2. (optional, design) A blocking Action items
Verified (tried to break, couldn't)The ordering claim is the load-bearing one and it holds under attack: This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on @aparajon's behalf after the adversarial correctness review above. The findings there are yours to pick up as follow-ups — flagging them, not gating on them.
This stamp was left by Claude Code (claude-opus-5).
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent.
PostgreSQL-only (pkg/engine/postgres/), nothing on the MySQL path, green CI, and +151 test lines against +104 production lines. It also strictly improves a path that fails outright today — a concurrent build reaching the transactional executor produces a raw server error, so there's no regression surface to speak of.
The things I checked rather than took on faith:
- The routing predicate is typed, not textual.
statement.Kind() == pgstatement.KindCreateIndex && statement.Concurrent()— no string match on "CONCURRENTLY", so it can't be fooled by casing, whitespace, or a comment, and it can't false-positive on an unrelated statement that happens to contain the word. - The budget ordering reasoning holds. 4 minutes under the 5-minute apply ceiling means the server-side deadline fires first, so exhaustion surfaces as the typed budget verdict instead of an ambiguous client-side cancellation. That's the right direction to get wrong-proof: the ambiguous outcome is the one you can't classify.
- The invalid-index handling is the best part. Checking
InvalidIndexErrorbefore the refusal and budget arms is subtle and correct — a budget-cancelled build leaves its own invalid index, and if the budget arm won the race the operator would be told about exhaustion when the actionable fact is the index they need to clear. The three-way split (own leftover → name the drop; pre-existing → checkpg_stat_activityfirst, it may be another actor's live build; unproven → inspectpg_index.indisvalid, never a statement to run) fails safe in the right direction each time, and building the detail only from typed identifiers rather than the wrapped errors keeps raw server text out of operator-facing output.
Worth noting for whoever sequences the PostgreSQL work: this PR gets the CREATE INDEX CONCURRENTLY constraint exactly right — a concurrent build can't run inside a transaction block, so it's routed to a dedicated non-transactional executor with its own budget and catalog verification. #1220 takes the opposite approach in the storage bootstrap path, emitting non-concurrent CREATE INDEX inside a per-table transaction, which holds ACCESS EXCLUSIVE for the build. Different contexts and I don't think this PR's approach transplants directly — but the reasoning here is the more careful of the two, and it's the one I'd want carried across if the bootstrap path ever grows index convergence on a large table.
Route
CREATE INDEX CONCURRENTLYto pg-sprite's dedicated concurrent-build executor and run partition admission at apply time in the PostgreSQL engine.Why
The optimistic apply path executed every native-safe statement through the transactional executor, but PostgreSQL refuses
CREATE INDEX CONCURRENTLYinside a transaction block — so a concurrent index build in a plan would fail at apply with a raw server error instead of being handled by the executor purpose-built for it. Partition admission also only ran at plan time, so a table partitioned between plan and apply would hit an unclassified server error mid-statement rather than a typed refusal.What
executeOptimisticroutes concurrentCREATE INDEXstatements toexecutor.BuildIndexConcurrentlyunder a dedicated 4-minute budget; plain (non-concurrent) index builds still go throughExecuteNative.preflight.CheckPartitionSupport) runs in the executing session for partitioned parents;*preflight.UnsupportedPartitionedParentErrorclassifies as a permanent refusal (unsupported-partitioned-parent) whose detail is pg-sprite's fixed English sentence — safe to render verbatim by construction.*executor.InvalidIndexError(pre-existing or unrecovered leftover invalid index) classifies as a retryable failure whose detail names the index and the operator action from typed fields only; the full cause goes to server logs.Before / after