Skip to content

Commit 4edf2e1

Browse files
os-warrenclaude
andauthored
test(service-automation): pin the node comparison with a correlation-less parking, and correct the comment that claimed it was already pinned (#15836)
* test(service-automation): pin the node comparison with a correlation-less parking `InMemorySuspendedRunStore.claimSuspension` compares two things, and only one of them was falsifiable. Both existing CONDITION cases re-park with a NEW correlation, so the correlation comparison rejects the stale claim first and masks the node comparison entirely: deleting `run.nodeId !== parkedAt.nodeId` alone left the three-file population fully green. Add the case that makes it falsifiable — a pausing executor that mints NO correlation, the shape `SuspensionParkedAt` documents as legitimate, where the node comparison is the only guard there is — and correct the CONDITION block's lead comment, which asserted a red that did not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * test(service-automation): re-measure the file's REVERT-PROOF ledger on the 62-test population Adding a case to the population invalidates every total the header quotes, and this file's whole subject is a measurement claim that had gone false. So all five legs were re-driven on origin/main d4f9b2a rather than adjusted on paper: (E) 10 red, (C) both comparisons 3 red, (C-node) 1 red, (C-corr) 1 red, (C2) 7 red. (C-node) and (C-corr) are new rows — splitting (C) into its two halves is what exposed the unfalsifiable one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5d02911 commit 4edf2e1

1 file changed

Lines changed: 166 additions & 20 deletions

File tree

packages/services/service-automation/src/concurrent-replica-resume-race.test.ts

Lines changed: 166 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,35 +45,55 @@
4545
* The per-process `this.resuming` stays as the cheap first gate; it is not
4646
* replaced.
4747
*
48-
* ## REVERT-PROOF — three mutations, all measured on the committed tree
48+
* ## REVERT-PROOF — five mutations, all measured on the committed tree
4949
*
5050
* Each was confirmed ON DISK before a single result was read (anchored counts
5151
* plus the blob hash) and restored inside a `trap ... EXIT INT TERM`, with the
5252
* restore proven by an empty `git diff HEAD` and a blob hash equal to HEAD's.
5353
* The population is these three files: this one, `suspended-run-store.test.ts`
54-
* and `multi-replica-resume-staleness.test.ts` — 61 tests.
54+
* and `multi-replica-resume-staleness.test.ts` — 62 tests.
5555
*
56-
* **(E) the engine stops asking.** Replace the `claimAdvance` call in
57-
* `resumeInternal` with the unconditional `forgetSuspendedRun(run, 'resumed')`
58-
* it had before this card: `Tests 9 failed | 52 passed (61)`. Seven here
59-
* (SHAPE A and SHAPE B on `expected [ 'notify', 'notify' ] to deeply equal
60-
* [ 'notify' ]`, SIZED on `{ trials: 25, doubled: 25, extraOpens: 25 }`, both
61-
* CONDITION cases, the loser's `debug` trace and the declared degradation) and
62-
* two in `suspended-run-store.test.ts` (the two-engines race over the durable
63-
* store, and the throwing claim). `multi-replica-resume-staleness.test.ts`
64-
* stays 8/8: the mutation is targeted, and #13617's own ledger is untouched.
56+
* ⚠️ RE-MEASURED IN FULL at #14956, on `origin/main` `d4f9b2a9d`. The counts
57+
* below are that run's, not #14333's: the third CONDITION case added there
58+
* joins the population, so every total moved by one and one leg — (C-node) —
59+
* turned out to have been reported wrong. Splitting (C) into its three
60+
* separate deletions is the point of that card; see the CONDITION block below.
61+
*
62+
* **(E) the engine stops asking.** Make `resumeInternal` never consult the
63+
* store: pin its `claim` to a kind that is none of `claimed` / `lost` /
64+
* `unavailable`, so no branch fires and `forgetSuspendedRun(run, 'resumed')`
65+
* deletes unconditionally — the pre-#14333 shape. `Tests 10 failed |
66+
* 52 passed (62)`. Eight here (SHAPE A and SHAPE B on `expected
67+
* [ 'notify', 'notify' ] to deeply equal [ 'notify' ]`, SIZED on
68+
* `{ trials: 25, doubled: 25, extraOpens: 25 }`, all THREE CONDITION cases,
69+
* the loser's `debug` trace and the declared degradation) and two in
70+
* `suspended-run-store.test.ts` (the two-engines race over the durable store,
71+
* and the throwing claim). `multi-replica-resume-staleness.test.ts` stays
72+
* 8/8: the mutation is targeted, and #13617's own ledger is untouched.
6573
*
6674
* **(C) the condition stops being a condition.** Delete BOTH comparisons from
6775
* `InMemorySuspendedRunStore.claimSuspension`, leaving an existence-only
68-
* consume: `Tests 2 failed | 59 passed (61)` — exactly the two CONDITION cases
69-
* below, and nothing else. That is the point of them. Every other race in this
70-
* file lets the loser lose by finding no row at all, which an existence check
71-
* satisfies too; before those two existed this mutation was measured GREEN
72-
* across the whole branch.
76+
* consume: `Tests 3 failed | 59 passed (62)` — exactly the three CONDITION
77+
* cases below, and nothing else. That is the point of them. Every other race
78+
* in this file lets the loser lose by finding no row at all, which an
79+
* existence check satisfies too; before those cases existed this mutation was
80+
* measured GREEN across the whole branch.
81+
*
82+
* **(C-node) only `run.nodeId !== parkedAt.nodeId`.** `Tests 1 failed |
83+
* 61 passed (62)`, and the one red is the correlation-less CONDITION case.
84+
* ⛔ Before that case existed this leg was `61 passed (61)` — ZERO red, on the
85+
* comparison the maintainer's ruling names verbatim ("delete only if still
86+
* parked at node N"). Both other CONDITION cases re-park with a NEW
87+
* correlation, so the correlation comparison rejects the stale claim first and
88+
* masks this one entirely.
89+
*
90+
* **(C-corr) only the correlation comparison.** `Tests 1 failed | 61 passed
91+
* (62)`, the one red being the CONDITION (correlation) case. Unchanged by
92+
* #14956 — this half was falsifiable all along.
7393
*
7494
* **(C2) the production store loses its predicate.** Delete `multi: true` from
7595
* the one `delete` call in `ObjectStoreSuspendedRunStore.claimSuspension`:
76-
* `Tests 7 failed | 54 passed (61)`, every one of them in
96+
* `Tests 7 failed | 55 passed (62)`, every one of them in
7797
* `suspended-run-store.test.ts`, failing with the PRODUCER's own refusal —
7898
* "Delete names one row by primary key, but options.where also carries
7999
* predicate keys 'node_id', 'correlation' ... For a conditional
@@ -375,9 +395,29 @@ describe('#14333 concurrent resumes of one run on two replicas advance it exactl
375395
// claim lands AFTER the winner has already advanced and RE-PARKED. Now a
376396
// row exists again, at a different parking, and an existence-only consume
377397
// deletes the parking another replica is standing on and traverses forward
378-
// from a snapshot that is two beats stale. The two tests below hold the
379-
// loser's claim until exactly that moment — one per comparison, so a
380-
// mutation that deletes only one of them still reddens.
398+
// from a snapshot that is two beats stale. The tests below hold the
399+
// loser's claim until exactly that moment.
400+
//
401+
// ⚠️ ONE PER COMPARISON IS NOT ENOUGH, and this comment said the opposite
402+
// until #14956 measured it. The first two cases below both re-park with a
403+
// NEW CORRELATION (`req_lv1`->`req_lv2`, `map:item_1`->`map:item_2`), so on
404+
// either of them the correlation comparison rejects the stale claim FIRST
405+
// and MASKS the node comparison entirely. Measured on the three-file
406+
// population (this file, `suspended-run-store.test.ts`,
407+
// `multi-replica-resume-staleness.test.ts`), deleting from
408+
// `InMemorySuspendedRunStore.claimSuspension`:
409+
//
410+
// WITHOUT the third case WITH it (today)
411+
// both comparisons 2 red / 61 tests 3 red / 62 tests
412+
// correlation alone 1 red 1 red
413+
// node alone ⛔ 0 RED 1 red
414+
//
415+
// The third case is what makes the node comparison falsifiable: a
416+
// CORRELATION-LESS parking, the shape `SuspensionParkedAt` documents as
417+
// legitimate ("a row persisted with no correlation has nothing to compare,
418+
// and the node condition still holds"). There the node comparison is the
419+
// ONLY guard there is, so deleting it alone reddens — `expected true to be
420+
// false`, the loser's stale claim granted and the action fired twice.
381421

382422
/** A promise with its resolver, for holding a claim open. */
383423
function latch(): { held: Promise<void>; release: () => void } {
@@ -549,6 +589,112 @@ describe('#14333 concurrent resumes of one run on two replicas advance it exactl
549589
expect((await shared.load(runId))?.correlation).toBe('map:item_2');
550590
});
551591

592+
/**
593+
* [#14956] A replica whose pausing executor mints NO CORRELATION.
594+
*
595+
* `SuspensionParkedAt.correlation` is optional by contract and
596+
* `SuspendedRunStore.claimSuspension`'s docblock names this shape
597+
* explicitly — "a row persisted with no correlation has nothing to
598+
* compare, and the node condition still holds". Every other case in this
599+
* file mints one, which is exactly why the node comparison was unfalsifiable
600+
* here: with a correlation present the correlation test rejects a stale
601+
* claim before the node test is ever consulted.
602+
*/
603+
function uncorrelatedReplica(store: SuspendedRunStore, led: Ledgers): AutomationEngine {
604+
const engine = new AutomationEngine(silentLogger(), store);
605+
engine.registerNodeExecutor({
606+
type: 'approval_level',
607+
descriptor: defineActionDescriptor({
608+
type: 'approval_level',
609+
version: '1.0.0',
610+
name: 'Approval level',
611+
supportsPause: true,
612+
resumeAuthority: 'service',
613+
}),
614+
async execute(node) {
615+
led.opened.push(node.id);
616+
// ⛔ No `correlation` — the whole point of the case below. An
617+
// approvals service that keys its request row by `runId` alone
618+
// mints nothing here, and it is entitled not to.
619+
return { success: true, suspend: true };
620+
},
621+
});
622+
engine.registerNodeExecutor({
623+
type: 'notify_action',
624+
descriptor: defineActionDescriptor({
625+
type: 'notify_action',
626+
version: '1.0.0',
627+
name: 'Notify',
628+
}),
629+
async execute(node) {
630+
led.fired.push(node.id);
631+
return { success: true };
632+
},
633+
});
634+
engine.registerFlow('expense_approval', APPROVAL_FLOW);
635+
return engine;
636+
}
637+
638+
it('THE CONDITION (node, correlation-less): with nothing to correlate, the node test alone loses the stale claim', async () => {
639+
const shared = new InMemorySuspendedRunStore();
640+
const led = ledgers();
641+
const gate = latch();
642+
const seen: Array<{ nodeId: string; correlation?: string }> = [];
643+
const a = uncorrelatedReplica(client(shared), led);
644+
const b = uncorrelatedReplica(delayedClaimClient(shared, gate.held, seen), led);
645+
646+
const submitted = await a.execute('expense_approval');
647+
expect(submitted.status).toBe('paused');
648+
const runId = submitted.runId!;
649+
expect(led.opened).toEqual(['lv1']);
650+
651+
// The precondition that makes this case different from the two above,
652+
// asserted rather than assumed: the persisted row carries NO
653+
// correlation, so `claimSuspension` has exactly one field to test.
654+
const parked = await shared.load(runId);
655+
expect(parked?.nodeId).toBe('lv1');
656+
expect(parked?.correlation).toBeUndefined();
657+
658+
// B's decision arrives first and reads the run at `lv1` — then its
659+
// claim stalls (a slow client, a queued statement, a paused container).
660+
const bDecision = approve(b, runId);
661+
662+
// A's decision lands and completes: `notify` fires once and the run
663+
// RE-PARKS at `lv2`. A row for this run exists again.
664+
const aResult = await approve(a, runId);
665+
expect(aResult.success).toBe(true);
666+
expect(aResult.status).toBe('paused');
667+
expect(led.fired).toEqual(['notify']);
668+
expect(led.opened).toEqual(['lv1', 'lv2']);
669+
670+
// B is claiming the parking it READ, and the claim carries no
671+
// correlation key at all — not an empty one. `nodeId` is the only
672+
// thing the store can compare.
673+
expect(seen).toEqual([{ nodeId: 'lv1' }]);
674+
675+
// Now B's claim reaches the store.
676+
gate.release();
677+
const bResult = await bDecision;
678+
679+
// ⛔ It must LOSE, and ONLY the node comparison can make it lose.
680+
// Delete `run.nodeId !== parkedAt.nodeId` and this line reads
681+
// `expected true to be false`.
682+
expect(bResult.success).toBe(false);
683+
expect(bResult.code).toBe('RESUME_IN_PROGRESS');
684+
expect(bResult.status).toBeUndefined();
685+
686+
// The observable effects are unchanged by the loser. Without the node
687+
// comparison this reads `[ 'notify', 'notify' ]` — the doubled side
688+
// effect this whole file exists to measure.
689+
expect(led.fired).toEqual(['notify']);
690+
expect(led.opened).toEqual(['lv1', 'lv2']);
691+
692+
// And the winner's parking SURVIVED rather than being consumed out
693+
// from under it, which is the half a doubled effect alone misses.
694+
expect(await a.hasSuspendedRun(runId)).toBe(true);
695+
expect((await shared.load(runId))?.nodeId).toBe('lv2');
696+
});
697+
552698
it('the loser is traced at DEBUG — an ordinary outcome, not a degradation', async () => {
553699
// The caller is told in the result, so this line exists only for an
554700
// operator reconstructing a race. At `warn` a busy any-of level would

0 commit comments

Comments
 (0)