You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
service-automation: two loose ends on the #14333 claim path — the loser's hot-cache entry is never evicted, and the 'unsupported' branch answers only after its conditional delete has already been issued #15832
Filed bare (no labels, no assignee) — routing is the PM's.
This is the grouped follow-up the #14333 review's §5 promised for its notes 1 and 5. #14956 asked whoever took it to check whether the card exists and file it if not, rather than assuming either way. I checked: a targeted issue search returned #14956 itself, #15222 (restoreConsumedSuspension / nested runs — a different finding), and #9315 (unrelated, domain:skills); a local grep over packages/services/service-automation/src/** and docs/ for the notes' own wording found nothing. It did not exist. Both notes below are re-verified against origin/maind4f9b2a9d, not carried over on the review's word.
Neither is a regression from #14333 — both are seams that PR created and left open.
Note 1 — the loser's hot-cache entry is never evicted
AutomationEngine has exactly one eviction site for this.suspendedRuns, and it is inside forgetSuspendedRun:
packages/services/service-automation/src/engine.ts:2037 — the only suspendedRuns.set
packages/services/service-automation/src/engine.ts:2107 — the only suspendedRuns.delete
The claim.kind === 'lost' branch in resumeInternal (around engine.ts:5358) returns RESUME_IN_PROGRESSbefore reaching forgetSuspendedRun, so the losing replica keeps its entry. Nothing else evicts it: the winner's forgetSuspendedRun runs in the other process, and there is no sweep over that map.
Severity, stated honestly. This is not a correctness bug on the read path. loadSuspendedRunStrict is store-first when a store is attached (engine.ts:4988-4999), so the stale entry is not read back — and the one case that does fall back to the cache, cacheOnlySuspensions, is short-circuited to unguarded in claimAdvance and never reaches the 'lost' branch at all. What is left is an unbounded per-process leak: one retained SuspendedRun snapshot (variables, context, screen) per lost claim, for the life of the process. On a busy any-of approval level — the very shape #14333 is about — every loser leaks one, forever.
Note 2 — the non-count 'unsupported' branch answers after the delete has already gone out
The refusal is decided on the shape of the return value, which is only known once the delete has already been issued against the shared row. So on a driver whose multi-delete resolves to something other than a number, the compare-and-set has been performed and its verdict thrown away. The engine then reads 'unsupported' as unguarded (claimAdvance), and forgetSuspendedRun issues a second, unconditional delete of the same row.
Two costs, and the second is the one worth a card:
a redundant delete round-trip per resume, for the life of the process;
A store that cannot express the condition should determine that before mutating, e.g. by capability-probing the engine once (the typeof this.engine.delete !== 'function' check directly above already sets that precedent) rather than by inspecting the result of a write it has already committed.
Not in scope of the PR that found this
Found while implementing #14956 (the correlation-less node-comparison pin). That PR is test-only and changes no behaviour here; #14956 remains open on its own terms and is not addressed by this card.
Filed bare (no labels, no assignee) — routing is the PM's.
This is the grouped follow-up the #14333 review's §5 promised for its notes 1 and 5. #14956 asked whoever took it to check whether the card exists and file it if not, rather than assuming either way. I checked: a targeted issue search returned #14956 itself, #15222 (
restoreConsumedSuspension/ nested runs — a different finding), and #9315 (unrelated,domain:skills); a local grep overpackages/services/service-automation/src/**anddocs/for the notes' own wording found nothing. It did not exist. Both notes below are re-verified againstorigin/maind4f9b2a9d, not carried over on the review's word.Neither is a regression from #14333 — both are seams that PR created and left open.
Note 1 — the loser's hot-cache entry is never evicted
AutomationEnginehas exactly one eviction site forthis.suspendedRuns, and it is insideforgetSuspendedRun:packages/services/service-automation/src/engine.ts:2037— the onlysuspendedRuns.setpackages/services/service-automation/src/engine.ts:2107— the onlysuspendedRuns.deleteThe
claim.kind === 'lost'branch inresumeInternal(aroundengine.ts:5358) returnsRESUME_IN_PROGRESSbefore reachingforgetSuspendedRun, so the losing replica keeps its entry. Nothing else evicts it: the winner'sforgetSuspendedRunruns in the other process, and there is no sweep over that map.Severity, stated honestly. This is not a correctness bug on the read path.
loadSuspendedRunStrictis store-first when a store is attached (engine.ts:4988-4999), so the stale entry is not read back — and the one case that does fall back to the cache,cacheOnlySuspensions, is short-circuited tounguardedinclaimAdvanceand never reaches the'lost'branch at all. What is left is an unbounded per-process leak: one retainedSuspendedRunsnapshot (variables, context, screen) per lost claim, for the life of the process. On a busy any-of approval level — the very shape #14333 is about — every loser leaks one, forever.Note 2 — the non-count
'unsupported'branch answers after the delete has already gone outpackages/services/service-automation/src/suspended-run-store.ts,ObjectStoreSuspendedRunStore.claimSuspension(~:388):The refusal is decided on the shape of the return value, which is only known once the delete has already been issued against the shared row. So on a driver whose multi-delete resolves to something other than a number, the compare-and-set has been performed and its verdict thrown away. The engine then reads
'unsupported'asunguarded(claimAdvance), andforgetSuspendedRunissues a second, unconditional delete of the same row.Two costs, and the second is the one worth a card:
A store that cannot express the condition should determine that before mutating, e.g. by capability-probing the engine once (the
typeof this.engine.delete !== 'function'check directly above already sets that precedent) rather than by inspecting the result of a write it has already committed.Not in scope of the PR that found this
Found while implementing #14956 (the correlation-less node-comparison pin). That PR is test-only and changes no behaviour here; #14956 remains open on its own terms and is not addressed by this card.
Refs: #14333 / PR #14712 (merged) · #14956