Skip to content

Attempt numbers never repeat, and an outcome is never lost - #141

Merged
arpanghoshal merged 12 commits into
mainfrom
v0.7/3a-attempt-integrity
Sep 11, 2026
Merged

arpanghoshal merged 12 commits into
mainfrom
v0.7/3a-attempt-integrity

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

v0.7 item 3a (SPEC-v0.7 §4.4, §5.5, §5.6, §8.3a). Attempt numbers never repeat, and an outcome is never lost. Item 3's idempotency token and item 4's ceiling both rest on this, so it lands before either.

Every defect here is in shipped 0.6.1 code, found by reading it against the v0.7 spec and then reproduced deterministically before being fixed. Four review rounds.

What was wrong

  • A stale renewal wrote an attempt number twice. The renewal UPDATE matched on effect_key and state only, so a caller that read FAILED at k, stalled while another renewed to k+1, ran and failed, then wrote k+1 a second time. Two dispatches, one number.
  • A lost COMMIT returned a number it did not write. Both resolve paths re-issued the reservation and then returned the original plan, so the caller held k+1 while the store held k+2.
  • The "did my COMMIT land?" check trusted any reserved row under our action_id, so a second process renewing under the same id left two dispatches holding one attempt. It now uses v0.6 §4.3.3's whole-row identity check.
  • The attempt number could go backwards, which is what made the first fix's premise false: _write_effect and _transition set attempt from their own earlier read while matching on (effect_key, action_id, state). A human's stale resolve_effect(failed) could overwrite attempt 2's record with attempt 1, and attempt 2 was then handed out again.
  • An unknown outcome could vanish. On both backends, a late outcome write could reach _checked, raise InvalidArgument into Control, and produce no receipt and no EXECUTION_AMBIGUOUS event while the record read FAILED and a retry was permitted. An executor's timeout disappeared from the evidence.
  • _expire overwrote a concurrent consumption, so an approval that authorised a real effect could read expired.

What it does now

Every UPDATE on an effect record carries the attempt it read (on Postgres; SQLite's writes sit inside BEGIN IMMEDIATE). A moved row matches nothing, is re-read, and is refused with the exception the found record earns, naming the move. commit_effect and mark_ambiguous are re-issued once against the re-read so a terminal outcome is never dropped; begin_execution and fail_effect are refused there, because a stale FAILED must never land on a running attempt. Control records the event and the receipt whatever the store answers, names the refusal in the recorded error, and re-raises the caller's own exception.

The bound, proved and tested

The re-issue and the lost-commit re-issue each set a flag that is never cleared and that the other now passes through, so every recursive call strictly increases the flags set: at most three frames, and three is reachable. An earlier version let the two bounds clear each other, which recursed to RecursionError at 381 deep. That is T155f's failure mode, and a RecursionError is outside the closed error set, so no caller could classify it. The test asserts depth 3 over two interleavings found by search, because a test asserting only "this did not blow the stack" was green for each flag dropped alone (measured, not assumed).

Evidence

  • Gate: 2981 passed with Postgres, 2905 passed / 70 skipped without. 24 new tests, 21 of them Postgres, in tests/test_attempt_integrity.py.
  • Every new test was red first against the unfixed code, each for its own defect, with the failure text quoted in the PR discussion.
  • Mutation table: 31 rows across four rounds, all killed. Three lines are declared equivalent or unreachable rather than counted: SQLite's AND attempt = ?, the carries_outcome guard, and _resolve_lost_renewal's final raise.
  • The test proxy gained one mode, arm(predicate), which holds a single statement on a single connection and refuses to arm over a pending hold. No second proxy.
  • Reviewed independently in four rounds. Rounds 1 to 3 each found a blocking defect, including one introduced by the previous round's fix. Round 4 is clean.

Residual, stated rather than fixed

With a reused action_id, a late transition can still attribute an outcome to the newer attempt, and a late fail_effect can permit a dispatch beside a running one. Closing it needs the attempt on every transition, which the frozen StateStore signatures do not carry; the store-side memo that would do it is written down in §12.3a and declined here. resolve_effect also carries no attempt, so the window a human actually has, inspect then decide then resolve, is wider than the one this closes. Both are in §12.3a and the CHANGELOG.

main has moved; git merge-tree reports zero conflicts. The api drift is two "defined at line" numbers; item 6 regenerates.

SPEC-v0.7 section 5.6. Two defects in the 0.6.1 Postgres store, each
reproduced by a test that was red before the fix, and a third found
while building:

- The stale renewal. The renewal UPDATE matched effect_key and state
  only, so a renewal planned against attempt k could land after another
  process had renewed to k+1, run and failed, and write k+1 a second
  time. It is now also conditioned on the attempt it was planned from,
  taken from the plan, with the row count checked. SQLite carries the
  same clause, where it is an equivalent mutant.
- The lost COMMIT. A reservation whose COMMIT was lost and re-issued
  returned the original plan's reservation, not the re-issue's. Both
  resolve functions now return the reservation on the record.
- The renewal's lost-commit re-read took any RESERVED record under its
  action_id as its own write. It now applies the v0.6 section 4.3.3
  whole-row identity check, as the insert path already did.

T246 (two windows) and T246b (renewal, insert and landed variants), each
through both reservation methods, in tests/test_attempt_integrity.py:
separate OS processes against Postgres, driven by the test-owned proxy,
which gains a hold_when mode that holds one statement on one connection.
No new StateStore method. Section 12.3a records what building decided.
… review)

The review's blocking finding. The renewal fix assumed the attempt number
only moves by a renewal, and on Postgres it did not: _write_effect (under
resolve_effect, extend_lease, hold_continuation and the kept AMBIGUOUS
write) and _transition (under the four transitions) wrote back the
attempt they had read under a WHERE on effect_key, action_id and state
alone. A caller retrying one Action reuses its action_id, so a stale
resolve decided on AMBIGUOUS at 1 could write FAILED at 1 over AMBIGUOUS
at 2, and the next renewal handed out attempt 2 again. Both
compare-and-sets now carry AND attempt = <the attempt read>; a moved row
matches nothing and is refused.

T246c holds each shape with the test proxy (resolve_effect, a
non-owner's AMBIGUOUS write, mark_ambiguous); all three were red first.

Also from the review: Proxy.hold_when becomes arm(), which clears
holding in place, gives each arming its own release event and refuses to
arm over a pending hold, with a server-free test that arms twice; the
statement_of docstring says what psycopg's prepare threshold means for
it; and T246b gains the insert-refuse variant, the interleaving that
reaches _resolve_lost_insert's last refusal, which no test did.
SPEC-v0.7 section 5.6 states the monotonicity the renewal fix rests on
and what made it true; section 8.3a gains T246c; section 10 gains two
rows; section 9.6 item 8 and section 12.3a record the review, the three
UPDATEs checked, the attribution residual a reused action_id still
leaves on every backend, and the corrected note on the lost-insert
refusal. SPEC-v0.6 sections 4.2 and 4.3.4 carry amendment pointers, and
the changelog gains the stale-write fix under Fixed.
The blocking finding of the second review round. Conditioning every
effect write on the attempt it read stopped the rewind, and then refused
writes that carry an outcome: a commit_effect or mark_ambiguous whose
record had moved wrote nothing and raised, so the effect record, which
gates the next renewal, said nothing about an attempt that may have
acted. The reviewer measured a renewal to attempt 3 with attempt 1's
commit recorded nowhere, which for a refund that landed is a second
refund.

Both outcome transitions are now re-issued once against the re-read,
bounded as Table A2's re-issue is, so the outcome lands where the same
call a moment later would have landed it. begin_execution and
fail_effect are still refused there: FAILED asserts that nothing
happened, and re-issuing it over a running attempt would permit a retry
beside a live dispatch.

With it, four smaller findings from the same round:

- the refusal on a moved record was always DuplicateEffect(in_progress),
  which errors.py defines as a live reservation; it now takes its type
  from what the re-read found and its message names the move;
- Control caught two store refusals around its outcome writes, so a
  record a human resolved while the attempt was still running produced
  no receipt, no EXECUTION_AMBIGUOUS event, and a store error in place
  of the executor's exception. It now catches every CTRLRunError there,
  records the evidence whatever the store answered, and names the
  refusal in it. T246d covers both backends;
- _expire wrote over a consumption that committed after its read; it
  carries the status it read, as every other approvals write does;
- release() pre-released a hold that had not fired, and statement_of's
  docstring understated the prepare threshold, which is six executions.
SPEC-v0.7 section 5.6 says what a re-read does with a write that carries
an outcome and what it does with one that claims a reservation, and
separates the store race this closes from the longer window a human
stands in, which needs an attempt argument on the CLI and is a
follow-up. Section 8.3a restates T246c and adds T246d. Section 10 gains
five rows. Section 12.3a carries the dropped-outcome finding, the
truthful refusal, the evidence rule, the approvals write, the proxy
holes, and a residual paragraph corrected in three places: a late
fail_effect is a concurrent double execution and not misattribution, a
reconcile hook reaches it with no human, and the store-side attempt memo
that would close it is declined here with its reasons.
The round-2 mutation run left one row alive: removing the re-issue's
bound failed no test, because a second move under the re-issue needs a
rival interleaved inside it and a proxy hold fires once. v0.6's T155f is
the precedent for what that costs, so the bound is driven the same way,
at the store's own read: a subclass that reports the record one attempt
further on every read is a rival that never stops moving, and every
conditional UPDATE then misses. Bounded, the call refuses and writes
nothing; unbounded, it recurses until Python stops it.
The blocking finding of the third review round. The stale re-issue's
bound and the lost-commit re-issue's bound each permit one attempt, and
neither passed the other's flag on, so a lost COMMIT inside a restage
cleared the restage bound and a restage inside a lost-commit re-issue
cleared the lost-commit bound. Driven together, every COMMIT lost and a
record that keeps moving, they alternated to RecursionError at 113 deep:
T155f's failure mode by another door, and outside the closed error set.
Both flags now travel through both re-issues.

Its test drives both halves the way each is driven alone, the real proxy
at drop_before_commit = 1000 and the moving-read seam, and it uses the
read pattern a search found: the obvious every-other-read pattern ends
bounded even when the flags do not compose.

With it, four smaller findings from the same round:

- _unrecorded recorded the store's refusal and not what the executor
  did, so an executor that returned and one that raised NotExecuted left
  identical receipts. For the first the remote very likely acted. Both
  now reach the receipt, as the ambiguous branch already did;
- the re-issue is asked for by commit_effect and mark_ambiguous rather
  than inferred from the state being written, so a later transition to
  COMMITTED or AMBIGUOUS that is a decision rather than an outcome
  cannot inherit it; the state set stays as an assertion;
- the restage is logged before the re-issue, so one that then refuses is
  no longer invisible;
- section 12.3a records the mark_ambiguous route to a concurrent
  dispatch through eager reconciliation, and that a wiring-bug refusal
  now surfaces in the evidence rather than at the caller.
The round-3 mutation run showed the composition test was weaker than it
reads. Dropping both bound flags recurses; dropping either one alone
does not recurse on any read pattern of four or fewer, it permits one
re-issue more than the bound allows and then terminates, so a test
asserting "no RecursionError" was green for exactly the two mutants that
say each flag is load-bearing.

Each bound permits one re-issue, so three is the deepest nesting any
interleaving may reach, and that is what the test asserts now. The two
read patterns come from a search over this proxy rather than from
intuition: (0, 0, 1, 0) catches the restage dropping retrying, and
(1, 0, 0, 0) catches the lost-commit re-issue dropping restaged.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 3bee9d21-4b0f-4ed1-85be-664b7bbe7916

📥 Commits

Reviewing files that changed from the base of the PR and between 36915dc and 54b32cd.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • docs/SPEC-v0.6.md
  • docs/SPEC-v0.7.md
  • src/ctrlrun/conformance/store/suites.py
  • src/ctrlrun/control.py
  • src/ctrlrun/postgres.py
  • src/ctrlrun/state.py
  • tests/failure_injection.py
  • tests/test_attempt_integrity.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread tests/test_attempt_integrity.py Fixed
@arpanghoshal
arpanghoshal merged commit 3dcc740 into main Sep 11, 2026
11 checks passed
@arpanghoshal
arpanghoshal deleted the v0.7/3a-attempt-integrity branch September 11, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants