feat(executor): run accepted blocking statements under both budgets - #107
Conversation
Keep future blocking execution fail-closed by admitting only typed refusal shapes whose lock risk can be meaningfully bounded. Distinguish one-relation index maintenance at the parse-only gate so broader forms remain ineligible.
Contain operator-accepted blocking SQL in an engine-owned transaction. Preserve typed lock, statement, execution, and ambiguous outcomes so later front-door wiring can report honestly without parsing error text.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…e accepted-blocking path
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/107, follow-up commit Both findings are fixed in the follow-up commit, which also merges
Decisions to veto
Source: #107, scratch review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 1/2 — the executor and what it admits. AB-1/AB-2, the design doc, and the stack are in 2/2. Reviewed
The head commit is the best thing in the diff. And 1. There are two admission surfaces and they disagree in both directions
That is not a wiring gap to be closed later, because a SQL-only check cannot reproduce the registry. The second eligible row is
Both directions are real: Wider than the registry. A plain non-concurrent Narrower than the registry. The design already names the missing piece. Step 2 of the rollout ( The fix that collapses both directions is to stop deriving admission from SQL: have 2. Every
|
| Form | current_setting('lock_timeout') / statement_timeout after the call |
|---|---|
SET LOCAL (as written) |
3s / 30s — the ambient values, 20/20 |
SET (mutant) |
137ms / 2468ms — the accepted-blocking budget, 20/20 |
So under the mutant every later user of that pooled connection silently inherits the operator's one-off blocking budget: a lock_timeout an order of magnitude tighter than the engine's own, applied to work that never asked for it. That is the same class of hazard as #81 ("keep the execution bounds where a pooler would drop them"), running the other way, and SET LOCAL is also what makes this correct under a transaction-pooling pooler — a real reason the keyword is load-bearing rather than stylistic.
AB-1 states it as the rule — "Transaction-local settings override ambient defaults" — so this is the invariant's mechanism with no test under it. One assertion closes it: after ExecuteAcceptedBlocking returns, read current_setting('lock_timeout') from the pool and require the ambient value. Against a pool with more than one connection that is probabilistic, so either force pool_max_conns=1 for that test or assert over enough reads to cover the pool; the twenty-read loop above was reliable here.
4. The ambiguous-commit branch is the fail-closed heart of the executor and nothing produces it in a test
Making tx.Commit failure return rep, nil instead of &BlockingOutcomeUnknownError{} (:117-120) leaves the suite green — a blocking DDL whose commit status is unknown reported as a clean success, with err == nil.
code_test.go:69 does pin BlockingOutcomeUnknownError → CodeBlockingOutcomeUnknown, and accepted_blocking_internal_test.go:74-79 pins the cancelled-context arm of acceptedBlockingStatementError. What has no coverage is the executor actually producing the error at the one boundary where the catalog and the client genuinely disagree. blocking-outcome-unknown is a first-class code in execution-model.md with the operator instruction "inspect the catalog before retrying"; it is the only outcome in this executor where the safe answer is "we do not know," and it is the one branch a mutation can delete for free.
A real commit failure is awkward to stage from an integration test, but the package already has the seam for this shape of problem — executeAcceptedBlocking takes a progress.Clock precisely so the exported wrapper stays clean. A narrow begin(ctx) (blockingTx, error) parameter on the internal function, with a fake whose Commit returns an error, pins the branch as a unit test and costs one interface with three methods. Worth it: this is the outcome the design leans on to justify the single attempt.
Smaller notes
2BP01is the nearest neighbour of the25001the head commit just fixed, and it is still retryable.DROP INDEX … CASCADEon a constraint-backed index returnsERROR: cannot drop index … because constraint … requires it (SQLSTATE 2BP01)— I confirmed against the server, and it lands indefault:→BlockingExecutionError→CodeExecutionFailed, whichPermanent()reports false. It will fail identically on every attempt. The single-attempt rationale atlock-budgeted-passthrough.md:214-221is that a second attempt "would re-queue behind the same holder, stack anotherlock_timeoutof blocked readers and writers"; a retryable code hands exactly that multiplication to the caller for a statement that can never succeed. Not necessarily this PR's job —CodeExecutionFailedis the right bucket for the deadlocks and disk-full errors that are retryable — but the head commit established that a deterministic server refusal earns a permanent classification, and this is the same shape one SQLSTATE over.DROP INDEX … CASCADEitself is fine, which is worth recording. I expected CASCADE to let a single-relationDROP INDEXreach a foreign key on a different table, breaking the "one table's lock is being accepted" premise the eligibility row rests on. It does not: PostgreSQL refuses to drop a constraint-backed index at all (the2BP01above), so an index's dependents are effectively constraints and constraints are already protected. The single-relation site holds.repis discarded on the two early failure paths.:102and:111returnBlockingReport{}afterrepwas populated at:98, so abeginorSET LOCALfailure loses the SQL and the budgets, while a statement failure (:115) keeps them. Either is defensible; the inconsistency is what will surprise whoever renders the report in step 3.ParseOneis what makestx.Exec(ctx, sql)safe, since a no-argument pgxExecgoes out over the simple protocol and would happily run a second statement.ParseOnerejectsn != 1(statement.go:192-194), so the admitted string is one statement by construction. Nothing to change — but it is the load-bearing reason this function can pass caller-supplied SQL straight through, and there is no comment at:113saying so.Attempts: 1on bothBudgetErrors matches the deliberate single attempt and reads correctly againstdocs/execution-model.md.
This review was generated by Claude Code (claude-opus-5).
|
🤖 2/2 — AB-1, AB-2, the design doc, and the stack. The executor and what it admits are in 1/2. The registry entries are the part of this change that outlives the code, so I read them as claims and checked each against what the executor actually enforces rather than against what the PR says it enforces. Both are true as written. What follows is four places where the text is narrower or wider than the mechanism, one acceptance criterion that now has no enforcement point anywhere, and the stack note. 1. AB-1's "engine-owned session" is a borrowed one, and the word carrying the guarantee is
|
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. Reviewed in two comments above — the executor and its admission surface in 1/2, the AB registry entries and design doc in 2/2. Ten mutations plus three server-side probes; base tests and lint green.
The head commit's 25001 handling is the best thing in the diff, and both new integration tests assert at the catalog rather than at the error type, which is the right instinct for an executor that takes ACCESS EXCLUSIVE.
Four findings, in the order I would fix them: the 57014 collapse (every cancellation, including an operator's pg_cancel_backend, is reported as statement-budget exhaustion with a false duration and a retryable code — the package already splits this three ways and this PR's own execution-model.md rows describe the case); the two admission surfaces disagreeing in both directions; SET LOCAL and the ambiguous-commit branch each unheld by a test; and the eligibility table not yet covering the two shapes the executor and registry disagree on.
This stamp was left by Claude Code (claude-opus-5).
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/107, follow-up commit Both findings are fixed in the follow-up commit, which also merges
Source: #107, scratch review |
Runs an operator-accepted blocking index statement in an engine-owned transaction under explicit lock and statement budgets.
Why
Today an operator who accepts a refused blocking form has to copy the SQL into
psql, losing the engine's lock-queue protection (a boundedlock_timeout) and its typed outcome contract.What
ExecuteAcceptedBlockingwith a validatedBlockingBudget(both bounds required, non-zero, representable): one statement, one engine-owned transaction,SET LOCALbounds on the executing session, no rewrite, exactly one attempt. The design doc explains why the accepted path does not reuse native execution's three-attempt lock retry: a second attempt re-queues behind the same holder and multiplies the budget the operator accepted.DROP INDEX/REINDEX INDEX|TABLE, non-concurrentCREATE INDEX); everything else is a typed admission error before any session is acquired.55P03→ lock budget (nothing ran),57014→ statement budget,25001→unsupported-accepted-blocking(permanent: the server will not runREINDEXon a partitioned relation inside the engine-owned transaction, and would refuse it identically on every retry), other PostgreSQL errors → execution failure, a failedCOMMITor caller cancellation after submission → outcome unknown until the catalog is inspected.REINDEX TABLE/REINDEX INDEXon a partitioned parent is reported as outside the path with the index untouched.The flag and the front-door wiring land in later PRs; nothing reaches this executor yet.
Before / after
Two accepted statements, both single-relation by shape. The first is the common maintenance-window case; the second is the shape the server cannot run inside a transaction.
🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.