Problem
StateTransitions documents the rule the engine lives by: under REPEATABLE READ a plain re-read cannot see a state another transaction committed after the snapshot began, so a contended state is flipped with a guarded UPDATE and never with a whole-entity save(). Two writers still save the whole token after a snapshot read.
WorkflowExecutor::handleAdvanceFailure(): the under-limit path does$fresh = loadUnchanged(); $fresh->setAttempts($attempts); ... $fresh->save();. The advance transaction has already rolled back and the token lock is released, so a concurrent worker can win the advance and consume the token between that read and that save. The save then writesstate = activeback overconsumed: the node runs twice. The dead-letter path right above it already goes throughclaimState(), andclearTokenAttempts()exists for exactly this reason ("written as a single column"), so the one path that is still a save() is the under-limit retry.TimeoutSweeper::fireInner(): after a non-resuming timeout action it re-arms with$current = load(); $current->setDeadline($rearmed)->save();inside the fire transaction, so the read is the transaction snapshot. A person completing the task in the same second commitsparked -> active -> consumed; the save writesstate = parkedback, and the run keeps a ghost parked token that never completes. The siblingfireTimerInner()re-arms the timer with a guardedUPDATE ... WHERE state = parked, so the two fire paths disagree.
Steps to reproduce
Queued mode, a node that throws a retryable failure with a retry policy above one attempt. Advance the token, and between the failure and the attempt write let another actor consume the row (in a kernel test: a raw UPDATE of the state column). The row reads active again afterwards. For the sweep: a parked task with a non-resuming timeout action; consume the token while the action runs; the row reads parked again with a fresh deadline.
Proposed resolution
Write the one column each caller owns, guarded on the state it read: the attempt count WHERE state = active and the deadline WHERE state = parked, through StateTransitions so the rule stays in one place, invalidating the token caches only when a row changed (RawUpdateTrait). A caller whose guard matched no row has been beaten to it and does nothing. Kernel tests in EngineDeadLetterTest and TimeoutTest that flip the row between the read and the write, and fail without the fix.
AI disclosure: this issue was drafted by Claude Code from an automated file-by-file audit of the module; the code and tests in the merge request are written by Claude Code, with the affected test classes run locally and the whole suite run by the project pipeline. The maintainer reviews and merges.
Issue fork orchestra-3624689
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
mably commentedComment #5
mably commented