Skip to content

Commit ca585bb

Browse files
committed
test(service-automation): pin the mid-park eviction window in persistSuspendedRun
`persistSuspendedRun` writes its `suspendedRuns` entry before it awaits the durable save and marks the run cache-only only after that save settles, so for the duration of the await the entry is live and unqualified. A concurrent per-id `loadSuspendedRunStrict` reads a store that truthfully has no row yet and takes the eviction path #16031 added, deleting an entry for a run being parked now. Measured on this head rather than inherited: the interleaving is REACHABLE on an ordinary single-process composition, and needs no out-of-band knowledge of the run id, because the map write is what publishes the id to `listSuspendedRuns`. Pinned at the bounded outcome the limit promises -- still resumable through the store-first strict loader, merely omitted from the cache-only listing -- plus the controls in which the window cannot bite. One compound case is pinned at its MEASURED behaviour and deliberately not fixed: when the save then FAILS, the run has neither a durable row nor a map entry and is unresumable, which escapes the resumability bound. Choosing between widening the cache-only marking, a lock, and reordering the save is a decision above this card. Comments at the window and on `evictConsumedSuspension` point at the pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 0ef4f80 commit ca585bb

2 files changed

Lines changed: 361 additions & 0 deletions

File tree

packages/services/service-automation/src/engine.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2034,6 +2034,22 @@ export class AutomationEngine implements IAutomationService {
20342034
* nobody read, and every in-flight approval zombified by the next restart.
20352035
*/
20362036
private async persistSuspendedRun(run: SuspendedRun): Promise<void> {
2037+
// [#16129] THE MAP WRITE IS FIRST, and `cacheOnlySuspensions` is written
2038+
// only after the save below settles => for the whole duration of that
2039+
// await this entry is in the map and is NOT yet qualified. A concurrent
2040+
// per-id `loadSuspendedRunStrict` therefore reads a store that
2041+
// truthfully has no row yet, finds no qualifier, and takes
2042+
// `evictConsumedSuspension` on a run being parked RIGHT NOW. Reachable
2043+
// without out-of-band knowledge of the id, because the map write is what
2044+
// publishes it to `listSuspendedRuns`.
2045+
//
2046+
// Bounded, measured, and pinned in
2047+
// `suspended-run-mid-park-eviction-window.test.ts`: the store-first
2048+
// strict load keeps the run resumable once the save lands, and the cost
2049+
// falls entirely on `listSuspendedRuns`, which merely OMITS the run --
2050+
// inside that listing's declared latitude. Do not widen the marking, add
2051+
// a lock, or move the save above this line without reading that pin's
2052+
// header: it also records the ONE compound case that escapes the bounds.
20372053
this.suspendedRuns.set(run.runId, run);
20382054
if (this.store) {
20392055
try {
@@ -2220,6 +2236,15 @@ export class AutomationEngine implements IAutomationService {
22202236
* costs cross-restart durability, not in-process resumability — into a
22212237
* run that vanishes from its own process.
22222238
*
2239+
* [#16129] Neither guard covers the MID-PARK WINDOW: `persistSuspendedRun`
2240+
* writes its map entry BEFORE it awaits the durable save, so an entry can be
2241+
* live here while the store legitimately has no row for it and the
2242+
* cache-only qualifier is not yet set. Evicting it is bounded -- the run
2243+
* stays resumable through the store-first strict load and only the
2244+
* cache-only listing under-reports -- and
2245+
* `suspended-run-mid-park-eviction-window.test.ts` pins both the window and
2246+
* the one compound case that escapes those bounds.
2247+
*
22232248
* A store read that THROWS must never reach here: an outage means the
22242249
* run's existence is UNKNOWN, not "gone". Every caller below is on a path
22252250
* where the store answered.
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16129] The MID-PARK WINDOW in {@link AutomationEngine.persistSuspendedRun}:
5+
* a concurrent per-id read can evict a LIVE map entry while the durable save is
6+
* still in flight.
7+
*
8+
* ## Why this file exists at all
9+
*
10+
* This is not a contract defect and must not be read as one. It is a real but
11+
* BOUNDED limit that was, until this file, undocumented and unpinned — and an
12+
* unpinned limit becomes folklore: the next reader cannot tell a deliberate
13+
* boundary from an oversight. Pinning it makes the boundary EXECUTABLE. A
14+
* comment saying the same thing is a claim that drifts away from the code; a
15+
* test that drives the interleaving cannot.
16+
*
17+
* ## The window, as measured on this head (not as inherited from the card)
18+
*
19+
* `persistSuspendedRun` writes the map entry FIRST and marks the run cache-only
20+
* LAST, and only on the failure path:
21+
*
22+
* 1. `this.suspendedRuns.set(run.runId, run)`
23+
* 2. `await this.store.save(run)` <- the window is this await
24+
* 3. on success: `cacheOnlySuspensions.delete(runId)`
25+
* on failure: `cacheOnlySuspensions.add(runId)`
26+
*
27+
* Between 1 and the resolution of 2 the entry is in the map and is NOT yet in
28+
* {@link AutomationEngine.cacheOnlySuspensions}. A concurrent
29+
* `loadSuspendedRunStrict` for that same id therefore reads a store that
30+
* truthfully answers "no row" (the save has not landed), finds no cache-only
31+
* qualifier, and takes the eviction path #16031 added — deleting an entry for a
32+
* run that is being parked right now.
33+
*
34+
* ## Reachability — measured, because the card recorded a reading and not a
35+
* measurement
36+
*
37+
* REACHABLE, on an ordinary single-process composition, and with no
38+
* out-of-band knowledge of the run id. The map write happens first, so
39+
* `listSuspendedRuns()` PUBLISHES the id during the window: the very
40+
* list-then-open consumer #16031 was written for can obtain the id and issue
41+
* the per-id read without the run having been handed to anyone yet. The second
42+
* test drives the same window on the RE-suspend path, where the id has been
43+
* public since the first park, so the reachability does not rest on the listing
44+
* either. Both need only a store whose `save` is asynchronous — that is every
45+
* real store.
46+
*
47+
* ## The two bounds this pin exists to keep standing
48+
*
49+
* - {@link AutomationEngine.loadSuspendedRunStrict} is STORE-FIRST while a
50+
* store is attached, so once the save lands the run is resumable from the
51+
* store. The evicted entry was a cache, not the authority.
52+
* - {@link AutomationEngine.listSuspendedRuns} merely OMITS the run.
53+
* Under-reporting is already inside that method's declared latitude (its own
54+
* docblock says it omits runs parked in a previous process lifetime);
55+
* over-reporting never was, which is the asymmetry #16031 rests on.
56+
*
57+
* ⛔ So this file does NOT widen the cache-only marking, add a lock, or move the
58+
* save before the map write. It pins the outcome those two bounds promise, and
59+
* a future change that alters the trade — in either direction — has to come
60+
* through here and say so.
61+
*
62+
* ## One measured case that does NOT stay inside those bounds
63+
*
64+
* `FINDING` below. Bound 1 holds only because the save eventually LANDS. Let the
65+
* save FAIL after an evicting read has already run, and the compound outcome is
66+
* a run with no durable row and no map entry: unresumable, and the engine's own
67+
* `error` record for the failed save promises the opposite ("it is kept in
68+
* memory only"). It is narrower than the base window — it needs a store that
69+
* rejects the write while still answering reads with "no row" rather than
70+
* throwing (a healthy read replica behind a broken write path, a missing INSERT
71+
* grant, a full disk) — but it is not hypothetical, and it escapes the bound.
72+
*
73+
* ⛔ It is deliberately NOT fixed here. Widening the cache-only marking is
74+
* exactly the move this card forbids taking unilaterally, and the choice
75+
* between that, a lock, and reordering the save is a decision above it. The
76+
* case is pinned at its MEASURED behaviour so the cost is visible and so any
77+
* future fix has a red test to turn green.
78+
*/
79+
80+
import { describe, it, expect } from 'vitest';
81+
import { defineActionDescriptor } from '@objectstack/spec/automation';
82+
import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts';
83+
import { AutomationEngine } from './engine.js';
84+
import { InMemorySuspendedRunStore } from './suspended-run-store.js';
85+
import type { SuspendedRun, SuspendedRunStore } from './engine.js';
86+
87+
function silentLogger(): any {
88+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } };
89+
}
90+
91+
/** start -> lv1 -> lv2 -> end. Two levels, so a re-suspend has somewhere to go. */
92+
const APPROVAL_FLOW = {
93+
name: 'expense_approval',
94+
label: 'Expense approval',
95+
type: 'autolaunched',
96+
nodes: [
97+
{ id: 'start', type: 'start', label: 'Start' },
98+
{ id: 'lv1', type: 'approval_level', label: 'Department head' },
99+
{ id: 'lv2', type: 'approval_level', label: 'General manager' },
100+
{ id: 'end', type: 'end', label: 'End' },
101+
],
102+
edges: [
103+
{ id: 'e1', source: 'start', target: 'lv1' },
104+
{ id: 'e2', source: 'lv1', target: 'lv2' },
105+
{ id: 'e3', source: 'lv2', target: 'end' },
106+
],
107+
} as any;
108+
109+
function engineOver(store: SuspendedRunStore | undefined): AutomationEngine {
110+
const engine = new AutomationEngine(silentLogger(), store);
111+
engine.registerNodeExecutor({
112+
type: 'approval_level',
113+
descriptor: defineActionDescriptor({
114+
type: 'approval_level',
115+
version: '1.0.0',
116+
name: 'Approval level',
117+
supportsPause: true,
118+
resumeAuthority: 'service',
119+
}),
120+
async execute(node: any) {
121+
return { success: true, suspend: true, correlation: `req_${node.id}` };
122+
},
123+
} as any);
124+
engine.registerFlow('expense_approval', APPROVAL_FLOW);
125+
return engine;
126+
}
127+
128+
const approve = (engine: AutomationEngine, runId: string) =>
129+
engine.resume(runId, { [RESUME_AUTHORITY_SERVICE]: true } as any);
130+
131+
/** Node ids the listing reports for `runId`, in call order. */
132+
const listedNodes = (rows: Array<{ runId: string; nodeId: string }>, runId: string) =>
133+
rows.filter(r => r.runId === runId).map(r => r.nodeId);
134+
135+
/**
136+
* A store whose `save` PARKS INSIDE THE WINDOW. `entered` resolves with the run
137+
* being saved the first time `save` is called — that is the instant between the
138+
* map write and the save landing — and nothing proceeds until `release()`.
139+
*
140+
* Every later `save` passes straight through the already-resolved gate, so a
141+
* re-suspend after the window is an ordinary park.
142+
*/
143+
function gatedSaveStore(
144+
inner: SuspendedRunStore,
145+
opts: { failSave?: boolean; loadThrows?: boolean } = {},
146+
): { store: SuspendedRunStore; entered: Promise<SuspendedRun>; release: () => void } {
147+
let announce!: (run: SuspendedRun) => void;
148+
const entered = new Promise<SuspendedRun>(r => { announce = r; });
149+
let open!: () => void;
150+
const gate = new Promise<void>(r => { open = r; });
151+
const store: SuspendedRunStore = {
152+
async save(run: SuspendedRun) {
153+
announce(run);
154+
await gate;
155+
if (opts.failSave) throw new Error('sqlite: attempt to write a readonly database');
156+
return inner.save(run);
157+
},
158+
async load(id: string) {
159+
if (opts.loadThrows) throw new Error('sqlite: database is locked');
160+
return inner.load(id);
161+
},
162+
delete: (id: string) => inner.delete(id),
163+
list: () => inner.list(),
164+
};
165+
return { store, entered, release: () => open() };
166+
}
167+
168+
// -- the window, and the bounds it stays inside -------------------------------
169+
170+
describe('#16129 — the mid-park window between the map write and the durable save', () => {
171+
it('THE WINDOW: a per-id read taken mid-park evicts a LIVE entry, and the listing hands out the id to do it with', async () => {
172+
const inner = new InMemorySuspendedRunStore();
173+
const { store, entered, release } = gatedSaveStore(inner);
174+
const engine = engineOver(store);
175+
176+
const parking = engine.execute('expense_approval'); // deliberately not awaited
177+
const parked = await entered; // now INSIDE the window
178+
const runId = parked.runId;
179+
180+
// Reachability without out-of-band knowledge of the id: the map write is
181+
// first, so the cache-only listing publishes the run mid-park...
182+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv1']);
183+
// ...while the store truthfully has no row for it yet.
184+
expect(await inner.load(runId)).toBeNull();
185+
186+
// The per-id read. The store answers "no row", the run is not cache-only,
187+
// so #16031's eviction path deletes an entry for a run being parked NOW.
188+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
189+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual([]);
190+
191+
release();
192+
expect((await parking).runId).toBe(runId);
193+
194+
// BOUND 1 — store-first: the run is resumable. The evicted entry was a
195+
// cache; the authority is the row that has now landed.
196+
expect(await inner.load(runId)).not.toBeNull();
197+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
198+
expect(await engine.getSuspendedScreen(runId)).not.toBeUndefined();
199+
200+
// BOUND 2 — the cost is confined to the cache-only listing, which OMITS the
201+
// run. Under-reporting is inside its declared latitude.
202+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual([]);
203+
// The durable listing is unaffected: it reads the store.
204+
expect(listedNodes(await engine.listSuspendedRunsDurable(), runId)).toEqual(['lv1']);
205+
206+
// Resumable END TO END, not merely answering `true` — and the next park
207+
// re-seeds the map, so the omission lasts one park, not forever.
208+
expect((await approve(engine, runId)).status).toBe('paused');
209+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv2']);
210+
expect((await approve(engine, runId)).success).toBe(true);
211+
});
212+
213+
it('THE WINDOW (re-suspend): the same eviction on a run whose id has been public since the first park', async () => {
214+
// The reachability here rests on nothing at all: an operator holding the id
215+
// from the first park issues an ordinary read while the SECOND park's save
216+
// is in flight. `claimAdvance` has already removed the durable row, so the
217+
// store's "no row" is again truthful and again not the whole truth.
218+
const inner = new InMemorySuspendedRunStore();
219+
const first = new InMemorySuspendedRunStore();
220+
const engine = engineOver({
221+
save: (run: SuspendedRun) => first.save(run),
222+
load: (id: string) => first.load(id),
223+
delete: (id: string) => first.delete(id),
224+
list: () => first.list(),
225+
});
226+
const runId = (await engine.execute('expense_approval')).runId!;
227+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv1']);
228+
229+
// Swap in the gated store over the same rows, then resume: lv1 -> lv2 parks
230+
// again, and THAT save is the one that waits.
231+
for (const r of await first.list()) await inner.save(r);
232+
const { store, entered, release } = gatedSaveStore(inner);
233+
engine.setSuspendedRunStore(store);
234+
235+
const resuming = approve(engine, runId);
236+
const reparked = await entered;
237+
expect(reparked.nodeId).toBe('lv2');
238+
239+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv2']);
240+
expect(await inner.load(runId)).toBeNull();
241+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
242+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual([]);
243+
244+
release();
245+
expect((await resuming).status).toBe('paused');
246+
247+
// Same two bounds.
248+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
249+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual([]);
250+
expect(listedNodes(await engine.listSuspendedRunsDurable(), runId)).toEqual(['lv2']);
251+
expect((await approve(engine, runId)).success).toBe(true);
252+
});
253+
});
254+
255+
// -- the one case that escapes the bounds, pinned at its measured behaviour ---
256+
257+
describe('#16129 — the window compounded with a FAILING save', () => {
258+
it('FINDING: an evicting read inside the window of a save that then fails leaves the run unresumable', async () => {
259+
// ⛔ Deliberately NOT fixed here — see this file's header. Pinned so the
260+
// cost is visible and so a future fix has a red test to turn green.
261+
const inner = new InMemorySuspendedRunStore();
262+
const { store, entered, release } = gatedSaveStore(inner, { failSave: true });
263+
const engine = engineOver(store);
264+
265+
const parking = engine.execute('expense_approval');
266+
const runId = (await entered).runId;
267+
268+
// Same window, same evicting read.
269+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv1']);
270+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
271+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual([]);
272+
273+
// The save now fails. `persistSuspendedRun` marks the run cache-only — but
274+
// the map entry it qualifies is already gone, so the qualifier qualifies
275+
// nothing and the strict loader has nothing left to serve.
276+
release();
277+
expect((await parking).runId).toBe(runId);
278+
279+
// ESCAPES BOUND 1. The store never took the row and the cache no longer
280+
// holds it, so the run is unresumable — while the engine's `error` record
281+
// for the failed save says it "is kept in memory only".
282+
expect(await inner.load(runId)).toBeNull();
283+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
284+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual([]);
285+
const resumed = await approve(engine, runId);
286+
expect(resumed.success).toBe(false);
287+
expect(resumed.code).toBe('RUN_NOT_FOUND');
288+
289+
// The control that isolates the window as the cause: WITHOUT the mid-park
290+
// read, the identical failing save is the documented degradation — the run
291+
// stays resumable in-process, which is exactly what the promise says.
292+
const solo = engineOver({
293+
async save() { throw new Error('sqlite: attempt to write a readonly database'); },
294+
async load() { return null; },
295+
async delete() {},
296+
async list() { return []; },
297+
});
298+
const soloRun = (await solo.execute('expense_approval')).runId!;
299+
expect(await solo.hasSuspendedRun(soloRun)).toBe(true);
300+
expect(listedNodes(solo.listSuspendedRuns(), soloRun)).toEqual(['lv1']);
301+
});
302+
});
303+
304+
// -- controls: the shapes in which the window cannot bite ---------------------
305+
306+
describe('#16129 — where the window does not exist', () => {
307+
it('CONTROL: with no store attached there is no window and nothing is evicted', async () => {
308+
// `persistSuspendedRun` awaits nothing, and `evictConsumedSuspension`
309+
// refuses to act because the map IS the authority.
310+
const engine = engineOver(undefined);
311+
const runId = (await engine.execute('expense_approval')).runId!;
312+
313+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
314+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv1']);
315+
expect((await approve(engine, runId)).status).toBe('paused');
316+
});
317+
318+
it('CONTROL: a read that THROWS inside the window evicts nothing — unknown is not "gone"', async () => {
319+
// The store-outage guard covers the window too: an unreadable store makes
320+
// the run's existence UNKNOWN, and the strict read throws rather than
321+
// reaching the eviction.
322+
const inner = new InMemorySuspendedRunStore();
323+
const { store, entered, release } = gatedSaveStore(inner, { loadThrows: true });
324+
const engine = engineOver(store);
325+
326+
const parking = engine.execute('expense_approval');
327+
const runId = (await entered).runId;
328+
329+
await expect(engine.hasSuspendedRun(runId)).rejects.toThrow(/database is locked/);
330+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv1']);
331+
332+
release();
333+
expect((await parking).runId).toBe(runId);
334+
expect(listedNodes(engine.listSuspendedRuns(), runId)).toEqual(['lv1']);
335+
});
336+
});

0 commit comments

Comments
 (0)