|
45 | 45 | * The per-process `this.resuming` stays as the cheap first gate; it is not |
46 | 46 | * replaced. |
47 | 47 | * |
48 | | - * ## REVERT-PROOF — three mutations, all measured on the committed tree |
| 48 | + * ## REVERT-PROOF — five mutations, all measured on the committed tree |
49 | 49 | * |
50 | 50 | * Each was confirmed ON DISK before a single result was read (anchored counts |
51 | 51 | * plus the blob hash) and restored inside a `trap ... EXIT INT TERM`, with the |
52 | 52 | * restore proven by an empty `git diff HEAD` and a blob hash equal to HEAD's. |
53 | 53 | * 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. |
55 | 55 | * |
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. |
65 | 73 | * |
66 | 74 | * **(C) the condition stops being a condition.** Delete BOTH comparisons from |
67 | 75 | * `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. |
73 | 93 | * |
74 | 94 | * **(C2) the production store loses its predicate.** Delete `multi: true` from |
75 | 95 | * 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 |
77 | 97 | * `suspended-run-store.test.ts`, failing with the PRODUCER's own refusal — |
78 | 98 | * "Delete names one row by primary key, but options.where also carries |
79 | 99 | * 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 |
375 | 395 | // claim lands AFTER the winner has already advanced and RE-PARKED. Now a |
376 | 396 | // row exists again, at a different parking, and an existence-only consume |
377 | 397 | // 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. |
381 | 421 |
|
382 | 422 | /** A promise with its resolver, for holding a claim open. */ |
383 | 423 | function latch(): { held: Promise<void>; release: () => void } { |
@@ -549,6 +589,112 @@ describe('#14333 concurrent resumes of one run on two replicas advance it exactl |
549 | 589 | expect((await shared.load(runId))?.correlation).toBe('map:item_2'); |
550 | 590 | }); |
551 | 591 |
|
| 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 | + |
552 | 698 | it('the loser is traced at DEBUG — an ordinary outcome, not a degradation', async () => { |
553 | 699 | // The caller is told in the result, so this line exists only for an |
554 | 700 | // operator reconstructing a race. At `warn` a busy any-of level would |
|
0 commit comments