state_managers: fix two read-only transaction participant bugs - #162
Draft
rjhuijsman wants to merge 3 commits into
Draft
state_managers: fix two read-only transaction participant bugs#162rjhuijsman wants to merge 3 commits into
state_managers: fix two read-only transaction participant bugs#162rjhuijsman wants to merge 3 commits into
Conversation
Stops a transaction that prepared successfully from being aborted because the coordinator asked a second time, which is what left `concurrent_transactions_same_state` hanging for its full Bazel timeout on MacOS arm64. Before this change, two behaviours that are each correct did not compose. A read-only participant elides its prepare/commit on the first `Prepare`: it marks the transaction prepared and committed in memory, drops its participant entry and releases its shared lock straight away, since it has nothing to persist and the coordinator skips it at commit. Separately, the coordinator retries `Prepare` indefinitely on any RPC-level error, because such an error says nothing about whether the participant prepared and so must never be read as an abort. Put together, a `Prepare` whose response was lost got re-sent to a participant that had forgotten the transaction precisely because it had succeeded. It answered `abort=True`, which the coordinator does treat as definitive, and a prepared transaction became an aborted one. The recovery path already avoids this by re-preparing with `skip_read_only=True`, "because by then read-only participants may have already forgotten the transaction"; the live retry loop had no equivalent. The coordinator now tells each participant, in `PrepareRequest`, whether it recorded that participant as read-only. A participant asked about a transaction it no longer holds can then answer prepared -- but only once its own restart detection confirms it has not restarted since the transaction began, which is the single way it could have lost the transaction rather than completed it. `abort=True` keeps meaning exactly one thing, and the coordinator needs no new interpretation of it. Old participants ignore the new field and answer `abort=True` as before; old coordinators never set it, so new participants keep today's behaviour. Both directions of a rolling upgrade degrade to what happens today. - Add five tests covering the elision, the re-sent `Prepare`, and the three cases that must still abort: an old coordinator, a coordinator that is not `read_only_aware`, and a participant that restarted or cannot detect a restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014L8UDXfhAJNKDLuEHN42AX
Current Aviator status
This pull request is currently open (not queued). How to mergeTo merge this PR, comment
See the real-time status of this PR on the
Aviator webapp.
Use the Aviator Chrome Extension
to see the status of your PR within GitHub.
|
rjhuijsman
commented
Sep 11, 2026
Stops a read-only participant from holding its state's shared lock for the life of the process after its coordinator crashes, which blocks every later exclusive claim on that state. Before this change, a participant whose watch of the coordinator reported the transaction committed went on to commit, whether or not it had ever prepared. A coordinator writes its participant list to disk and fans `Prepare` out concurrently, so it can crash with the list durably recorded and a read-only participant's `Prepare` never sent. That participant stays joined, unprepared, holding its shared lock. The recovered coordinator re-prepares with `skip_read_only=True` -- read-only participants may already have elided and forgotten the transaction -- and then answers this one's `Watch` with "committed". The database persists a participant transaction only once it is prepared and refuses to commit one that is not, so that commit failed: with restart detection "Missing transaction for state type ...", and on the legacy path "Txn not prepared". The watch loop treats the failure as transient, backs off and asks again, gets the same answer, and repeats forever, so `_complete_participant_transaction()` is never reached and the lock is never released. Aborting is the terminal outcome such a participant can still reach, and it is safe: a read-only participant has nothing to apply. A participant that did elide is already `finished()`, which makes the abort a no-op for it. - Add tests for both: the unprepared participant told to commit, and the elided participant tolerating a later abort. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014L8UDXfhAJNKDLuEHN42AX
state_managers: let a read-only participant answer a re-sent Preparestate_managers: fix two read-only transaction participant bugs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Written unattended by a robot (Claude Opus 5), and not yet reviewed by a human. The design was agreed with @rjhuijsman first; the code was not. It needs the author's review before it goes to peer review.
Two independent bugs in how read-only transaction participants reach a terminal outcome. Each one strands a participant holding its state's shared lock, and each has its own commit.
Bug 1: a re-sent
Prepareis answered "no such transaction"Two behaviours that are each correct on their own, and don't compose.
A read-only participant elides its prepare/commit. On the first
Prepareit marks the transaction prepared and committed in memory, drops its participant entry and releases its shared lock immediately. It has nothing to persist, and the coordinator skips it during the commit phase, so it never expects to be contacted again. That is the whole point — it stops holding a read lock across the rest of two-phase commit.The coordinator retries
Prepareindefinitely on any RPC-level error. Also deliberate: a transport error says nothing about whether the participant prepared. Concluding "abort" from one would be wrong, because the participant may be sitting there prepared and a later recovery could commit the whole transaction. So definitive outcomes travel only in response fields, never in gRPC status codes.Together: a
Preparewhose response is lost gets re-sent to a participant that has forgotten the transaction precisely because it succeeded. The participant answersabort=True— which the coordinator does treat as definitive — and a transaction that prepared perfectly becomes an aborted one.The codebase already knows this hazard in one place. The recovery re-prepare passes
skip_read_only=True, with the comment "by then read-only participants may have already forgotten the transaction." Same problem, fixed on the recovery path only; the live per-participant retry loop had no equivalent.Bug 1's fix
The coordinator tells each participant, in
PrepareRequest, whether it recorded that participant as read-only. A participant asked about a transaction it no longer holds may then answer prepared — but only after its own restart detection confirms it has not restarted since the transaction began, which is the one way it could have lost the transaction rather than completed it.Deliberately not done on the coordinator side by reinterpreting the response:
abort=Truekeeps meaning exactly one thing, andprepare()needs no new logic. The party that owns each fact supplies it — the coordinator knows the read-only classification, the participant is the only one that can know whether it lost state.Rolling upgrades degrade to today's behaviour in both directions, following the pattern
abort_via_responseandread_only_awarealready set: an old participant ignores the new field and answersabort=True, which every coordinator already treats as definitive; an old coordinator never sets it, so a new participant behaves as it does today.Bug 2: a participant is told to commit a transaction it never prepared
A coordinator writes its participant list to disk and fans
Prepareout concurrently, so it can crash with the list durably recorded and a read-only participant'sPreparenever sent. That participant stays joined, unprepared, holding its shared lock. The recovered coordinator re-prepares withskip_read_only=True— read-only participants may already have elided and forgotten the transaction — and then answers this one'sWatchwith "committed".The database persists a participant transaction only once it is prepared and refuses to commit one that is not, so the commit fails: "Missing transaction for state type ..." with restart detection, "Txn not prepared" on the legacy path. The watch loop treats that as transient, backs off, asks again, gets the same answer, and repeats forever — so
_complete_participant_transaction()is never reached and the lock is never released.The fix is to abort instead of committing when the transaction was never prepared. That is the terminal outcome such a participant can still reach, and it is safe because a read-only participant has nothing to apply; one that did elide is already
finished(), which makes the abort a no-op for it. Doing it participant-side rather than changing whatWatchanswers also keeps it working against an un-upgraded coordinator, and avoids relying on every member ofParticipants.read_only()being elidable —retain_as_read_only()can move a previously-committing participant into that set.Tests
Five, in
StateManagerTestCase. One reproduces the bug — it fails onmainwithAssertionError: True is not false. The other four pin down behaviour that must not change, and pass both before and after:Prepareon a read-only participantabort=FalsePrepareafter elidingabort=False— bug 1's fixPrepare, coordinator did not setread_onlyabort=TruePrepare, coordinator notread_only_awareabort=TruePrepareafter a restartabort=True,restart_detected=TruePreparewith no restart detection (UUIDv4)abort=TrueScope and what this does not settle
These came out of the
concurrent_transactions_same_stateMacOS-arm64 investigation; #158 is a third, separate bug and is independent (they touch different parts ofstate_managers.pyand do not conflict).The evidence that this is the mechanism behind that hang is strong but circumstantial, and the hang is not reproduced here: the 18 failing transactions in run
34212468575are all UUIDv7-stamped in the same millisecond and their failedPrepares land nine seconds later, which is a retry ladder rather than a first attempt; and those RPCs cross a local Envoy (the test log showsexecute program 'envoy'), so one transport event fails all 18 in-flight responses at once. The hang only reproduces on MacOS arm64 at roughly 1 in 3, and no MacOS runner was available here.One residual limit is called out in a comment at the new branch: the restart check is per process. Shards are fixed when a state manager is constructed, so a shard changes owner only via a new process, which the recovery timestamp catches. Reassigning a shard to an already-running server would need a per-shard recovery timestamp.
🤖 Generated with Claude Code
https://claude.ai/code/session_014L8UDXfhAJNKDLuEHN42AX