Issue #3608146: Consolidate the recovery layer behind one guarded transition primitive
Pays down the structural debt behind the alpha8 delta-audit (#3608117): the audits kept finding the same class of concurrency bug in different places because state changes were made ad hoc at each call site. This is a pure, behavior-preserving refactor. No runtime behavior changes; the full kernel suite stays green.
What changed
1. One guarded state-transition primitive
transition(ContentEntityInterface $entity, string $expected, string $new, array $extra = []): bool is now the single guarded status flip. It is a guarded UPDATE ... SET status = :new WHERE id = :id AND status = :expected, which current-reads the row and so wins only the single expected-to-new transition, even inside a caller's open transaction. On success it syncs the in-memory copy and invalidates the entity caches.
claimTerminalStatus()is now a thin wrapper over it that always stampschanged(retention needs it).handleAdvanceFailure()'s dead-letter ERROR claim routes through it.
A raw UPDATE does not fire entity save hooks, so it is deliberately reserved for contended flips (completion, cancellation, failure, dead-lettering). Single-actor operator transitions that must cascade through a save hook (an incident resolution, where orchestra_inbox cancels the work item on orchestra_token_update) stay an entity save() on purpose. That distinction is now documented, not accidental.
2. The isolation invariant, codified
The primitive's docblock states it once, authoritatively: under MySQL REPEATABLE READ a transaction reads from its snapshot, so a plain entity re-read (loadUnchanged(), or load-then-setStatus-then-save()) cannot see a status another transaction committed after the snapshot began, even under a lock. Never trust a re-read to observe a concurrent commit; guard the write instead. The loose "fresh status read" comment in checkCompletion() was corrected to point at this.
3. The two recovery sweeps consolidated
reconcileStuckInstances() and recoverStalledInstances() each re-derived "find running instances of a stuck shape, excluding incident-halted ones, in a bounded ordered batch." They now share:
stuckInstanceQuery($limit): the base finder (status = running, NOT EXISTS open incident, ordered by id, range-limited);sweepInstances($query, $handler, $log): execute + load + per-instance isolated try/catch;tokenInstanceColumn(): the repeated table-mapping boilerplate.
Each sweep now adds only its own shape subquery.
Minor intentional delta
Because the primitive is uniform, the token dead-letter path now also invalidates the token's cache tags after its ERROR flip (the old inline code reset the entity cache but did not invalidate tags). This is over-invalidation at worst (a spurious cache miss if the surrounding transaction later rolls back), never staleness, and invalidating a token's tags on a status change is the more correct behavior anyway.
Scope note
The exactly-once advance/join machinery (token park/consume, join arm, sibling consume) was verified sound by the delta-audit and is explicitly out of scope here: those are single-writer writes within an established claim, not contended re-reads, so they are labelled as such rather than rewrapped.
Testing
- Full orchestra kernel suite: green.
- phpcs (Drupal + DrupalPractice, warnings on): clean.
- Adversarial behavior-equivalence audit: performed.