Bugfix: workflow transactions recovered before commit were not exactly-once#98
Open
rjhuijsman wants to merge 1 commit into
Open
Bugfix: workflow transactions recovered before commit were not exactly-once#98rjhuijsman wants to merge 1 commit into
rjhuijsman wants to merge 1 commit into
Conversation
A workflow's idempotent transaction call executed (and committed) twice when a failure hit the window between the transaction's response returning to the workflow and its commit becoming durable: a transaction's response is returned right after two phase commit's durable prepare, with the commit finishing asynchronously. On restart the transaction was recovered as prepared and committed during recovery, but the recovered workflow re-executed concurrently and its duplicate call raced that commit and missed deduplication, so the user's transaction method ran a second time on top of the first commit. This made `test_workflow_idempotent_writer_and_transaction_survive_recovery` flaky on slow runners (`AssertionError: 2 != 1`); delaying every participant commit by one second reproduced the failure deterministically. Two gaps combined to cause the missed deduplication: 1. `DatabaseService::RecoverTransactionIdempotentMutations` scanned a recovered transaction's write batch only under the `idempotent-mutation-v4` key prefix, so the workflow-scoped and expiring uncommitted idempotent mutations introduced in 6a7e71429 recovered as an empty `uncommitted_idempotent_mutations`. Now the scan covers every idempotent mutation key prefix. 2. The in-flight duplicate-call guard in `check_for_idempotent_mutation()` (#5361) matched only `transaction.idempotency_key`, which is not persisted and is therefore `None` for a recovered transaction. Now the guard also matches the transaction's recovered uncommitted idempotent mutation keys, so a duplicate call awaits the recovered transaction and then serves its cached response. With (1), a recovered transaction carrying idempotent mutations can commit before any call has populated the per-state idempotent mutations cache, so `transaction_participant_commit()` now updates the cache entry only when present instead of assuming it exists. Added a deterministic regression test that pins the interleaving by delaying participant commits, in the style of the existing coordinator/participant failure tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPXVU5ytyWJxSL4YChKhm6
Current Aviator status
This PR is not ready to merge (currently in state pending): this PR has not been approved. Pending Status Checks
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.
|
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.
Before this fix, with a failure at the wrong time, a workflow's idempotent transaction call executed — and commits — twice. Discovered by a flaky test:
tasks_tests_py::TasksTestCase.test_workflow_idempotent_writer_and_transaction_survive_recoverywas flaky ever since it was written, and pointed us to this real bug.The race was as follows:
SidecarStateManager.transaction_participant_commit).rbt.down()cancels the pending commit tasks), leaving the transaction durably prepared but uncommitted — its idempotent-mutation record (key → response) persisted only inside the prepared transaction.check_for_idempotent_mutation()before that commit lands: the committed-mutations lookup misses, and the in-flight-transaction guard (reboot-dev/mono#5361) misses too, so the call starts a new transaction, waits behind the recovered transaction's exclusive lock, and re-executes the user's transaction method after the recovered one commits.In practice this bug is rare to see, because the prepared transaction being recovered typically completes its commit before the workflow re-executes - so the test usually passes. It's possible however for the workflow re-execution to win, in which case the transaction effects occur twice. 😬
There are two bugs that combined to produce this race:
DatabaseService::RecoverTransactionIdempotentMutationsscanned a recovered transaction's write batch only under theidempotent-mutation-v4key prefix. Commit db327a7 moved workflow-scoped mutations underworkflow-idempotent-mutation*key prefixes and updatedStore,Export, andRecoverIdempotentMutationsaccordingly — but notRecoverTransactionIdempotentMutations. A recovered prepared transaction therefore reported emptyuncommitted_idempotent_mutationsfor workflow calls. That same commit also introduced the flaky test, so the flake has existed since this code was written.check_for_idempotent_mutation()matches onlytransaction.idempotency_key, which is never persisted and is thereforeNonefor a recovered transaction.Fixes:
reboot/server/database.cc: scan the write batch once per idempotent-mutation key prefix (plain, expiring, and the three workflow-scoped ones) so a recovered prepared transaction knows all of its uncommitted idempotent mutations.reboot/aio/state_managers.py: the in-flight guard also matchescontext.idempotency_key in transaction.idempotent_mutations; a duplicate call then awaits the recovered transaction and serves its cached response.reboot/aio/state_managers.py:transaction_participant_commitnow tolerates the per-state idempotent-mutations cache entry being absent — possible once recovered transactions carry idempotent mutations and commit before any call has touched that state — instead of raisingKeyErrorin the exception-intolerant commit section. Skipping the update is safe: any later lookup recovers from the database, which at that point includes the committed mutations.tests/reboot/tasks_tests.py: new deterministic regression test.This ports reboot-dev/mono#5617 to the public repo.