@dudousxd/nestjs-durable-testing 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -208,6 +208,8 @@ declare function assertReplayable(register: (engine: WorkflowEngine) => void, hi
208
208
  */
209
209
  interface StateStoreContext {
210
210
  store: StateStore;
211
+ /** A second wrapper sharing the database/pool, used to exercise cross-instance claims. */
212
+ peerStore?: StateStore;
211
213
  cleanup: () => Promise<void>;
212
214
  /**
213
215
  * Whether the store's optional `transaction` can run an ASYNC work callback. Defaults to `true`.
package/dist/index.js CHANGED
@@ -399,7 +399,7 @@ async function assertReplayable(register, history) {
399
399
  __name(assertReplayable, "assertReplayable");
400
400
 
401
401
  // src/state-store-conformance.ts
402
- import { WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
402
+ import { SINGLETON_ADMITTED_TAG, WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
403
403
  import { beforeEach, describe as describe2, expect as expect2, it as it2 } from "vitest";
404
404
  var StateStoreUnavailableError = class extends Error {
405
405
  static {
@@ -449,6 +449,7 @@ var checkpoint = /* @__PURE__ */ __name((over = {}) => ({
449
449
  function runStateStoreContract(name, makeStore) {
450
450
  describe2(`StateStore contract: ${name}`, () => {
451
451
  let store;
452
+ let peerStore;
452
453
  let cleanup;
453
454
  let supportsAsyncTransaction = true;
454
455
  let supportsTagFilter = true;
@@ -458,6 +459,7 @@ function runStateStoreContract(name, makeStore) {
458
459
  try {
459
460
  const ctx = await makeStore();
460
461
  store = ctx.store;
462
+ peerStore = ctx.peerStore ?? ctx.store;
461
463
  cleanup = ctx.cleanup;
462
464
  supportsAsyncTransaction = ctx.supportsAsyncTransaction ?? true;
463
465
  supportsTagFilter = ctx.supportsTagFilter ?? true;
@@ -483,6 +485,197 @@ function runStateStoreContract(name, makeStore) {
483
485
  await fn();
484
486
  });
485
487
  }, "t");
488
+ t("atomically admits holders without displacement by later same-millisecond IDs", async () => {
489
+ expect2(store.tryAdmitSingleton).toBeTypeOf("function");
490
+ const claim = /* @__PURE__ */ __name((id, limit = 1) => store.tryAdmitSingleton?.(id, "singleton:k", "checkout", limit), "claim");
491
+ await store.createRun(run({
492
+ id: "z-holder",
493
+ tags: [
494
+ "singleton:k",
495
+ "user-tag"
496
+ ]
497
+ }));
498
+ expect2(await claim("z-holder")).toBe(true);
499
+ await store.updateRun("z-holder", {
500
+ status: "suspended",
501
+ wakeAt: 1e4
502
+ });
503
+ expect2(await claim("z-holder")).toBe(true);
504
+ await store.updateRun("z-holder", {
505
+ status: "blocked",
506
+ lockedBy: "expired",
507
+ lockedUntil: 0
508
+ });
509
+ await store.createRun(run({
510
+ id: "a-waiter",
511
+ tags: [
512
+ "singleton:k"
513
+ ]
514
+ }));
515
+ expect2(await claim("a-waiter")).toBe(false);
516
+ expect2(await claim("z-holder")).toBe(true);
517
+ await store.updateRun("z-holder", {
518
+ status: "cancelling"
519
+ });
520
+ expect2(await claim("a-waiter")).toBe(false);
521
+ await store.updateRun("z-holder", {
522
+ status: "completed"
523
+ });
524
+ expect2((await store.getRun("z-holder"))?.tags).toEqual([
525
+ "singleton:k",
526
+ "user-tag"
527
+ ]);
528
+ expect2(await claim("a-waiter")).toBe(true);
529
+ await store.updateRun("z-holder", {
530
+ status: "running"
531
+ });
532
+ expect2(await claim("z-holder")).toBe(false);
533
+ expect2(await claim("missing")).toBe(false);
534
+ });
535
+ t("singleton denial atomically parks waiters and preserves cancelling/terminal state", async () => {
536
+ await store.createRun(run({
537
+ id: "holder",
538
+ tags: [
539
+ "singleton:k"
540
+ ]
541
+ }));
542
+ expect2(await store.tryAdmitSingleton?.("holder", "singleton:k", "checkout", 1)).toBe(true);
543
+ await store.createRun(run({
544
+ id: "waiter",
545
+ tags: [
546
+ "singleton:k"
547
+ ]
548
+ }));
549
+ expect2(await peerStore.tryAdmitSingleton?.("waiter", "singleton:k", "checkout", 1, 12e3)).toBe(false);
550
+ expect2(await store.getRun("waiter")).toMatchObject({
551
+ status: "suspended",
552
+ wakeAt: 12e3
553
+ });
554
+ await store.updateRun("waiter", {
555
+ status: "cancelling",
556
+ wakeAt: void 0
557
+ });
558
+ expect2(await peerStore.tryAdmitSingleton?.("waiter", "singleton:k", "checkout", 1, 14e3)).toBe(false);
559
+ expect2(await store.getRun("waiter")).toMatchObject({
560
+ status: "cancelling"
561
+ });
562
+ expect2((await store.getRun("waiter"))?.wakeAt).toBeUndefined();
563
+ await store.updateRun("waiter", {
564
+ status: "cancelled"
565
+ });
566
+ expect2(await peerStore.tryAdmitSingleton?.("waiter", "singleton:k", "checkout", 1, 16e3)).toBe(false);
567
+ expect2((await store.getRun("waiter"))?.status).toBe("cancelled");
568
+ await store.updateRun("holder", {
569
+ status: "completed"
570
+ });
571
+ await store.createRun(run({
572
+ id: "cancelling",
573
+ status: "cancelling",
574
+ tags: [
575
+ "singleton:k"
576
+ ]
577
+ }));
578
+ expect2(await store.tryAdmitSingleton?.("cancelling", "singleton:k", "checkout", 1, 18e3)).toBe(false);
579
+ expect2((await store.getRun("cancelling"))?.tags).not.toContain(SINGLETON_ADMITTED_TAG);
580
+ });
581
+ t("competing store instances atomically claim an empty singleton slot", async () => {
582
+ for (const id of [
583
+ "a-first",
584
+ "b-second",
585
+ "c-third"
586
+ ]) {
587
+ await store.createRun(run({
588
+ id,
589
+ tags: [
590
+ "singleton:k"
591
+ ]
592
+ }));
593
+ }
594
+ const results = await Promise.all([
595
+ peerStore.tryAdmitSingleton?.("b-second", "singleton:k", "checkout", 1),
596
+ store.tryAdmitSingleton?.("a-first", "singleton:k", "checkout", 1),
597
+ peerStore.tryAdmitSingleton?.("c-third", "singleton:k", "checkout", 1)
598
+ ]);
599
+ expect2(results).toEqual([
600
+ false,
601
+ true,
602
+ false
603
+ ]);
604
+ expect2(await peerStore.tryAdmitSingleton?.("a-first", "singleton:k", "checkout", 1)).toBe(true);
605
+ expect2((await store.getRun("a-first"))?.tags).toContain(SINGLETON_ADMITTED_TAG);
606
+ });
607
+ t("concurrent singleton claims honor limits and independent keys/workflows", async () => {
608
+ const claim = /* @__PURE__ */ __name((id, tag = "singleton:k", workflow = "checkout") => store.tryAdmitSingleton?.(id, tag, workflow, 2), "claim");
609
+ for (const id of [
610
+ "z-holder",
611
+ "y-holder"
612
+ ]) {
613
+ await store.createRun(run({
614
+ id,
615
+ tags: [
616
+ "singleton:k"
617
+ ]
618
+ }));
619
+ expect2(await claim(id)).toBe(true);
620
+ }
621
+ for (const id of [
622
+ "a-waiter",
623
+ "b-waiter",
624
+ "c-waiter"
625
+ ]) {
626
+ await store.createRun(run({
627
+ id,
628
+ status: "pending",
629
+ tags: [
630
+ "singleton:k"
631
+ ]
632
+ }));
633
+ }
634
+ expect2(await Promise.all([
635
+ "a-waiter",
636
+ "b-waiter",
637
+ "c-waiter"
638
+ ].map((id) => claim(id)))).toEqual([
639
+ false,
640
+ false,
641
+ false
642
+ ]);
643
+ await store.updateRun("z-holder", {
644
+ status: "failed"
645
+ });
646
+ expect2(await Promise.all([
647
+ "c-waiter",
648
+ "b-waiter",
649
+ "a-waiter"
650
+ ].map((id) => claim(id)))).toEqual([
651
+ false,
652
+ false,
653
+ true
654
+ ]);
655
+ const active = await store.listRuns({
656
+ workflow: "checkout",
657
+ statuses: [
658
+ "running",
659
+ "pending"
660
+ ]
661
+ });
662
+ expect2(active.filter((r) => r.tags?.includes(SINGLETON_ADMITTED_TAG))).toHaveLength(2);
663
+ await store.createRun(run({
664
+ id: "other-key",
665
+ tags: [
666
+ "singleton:other"
667
+ ]
668
+ }));
669
+ expect2(await claim("other-key", "singleton:other")).toBe(true);
670
+ await store.createRun(run({
671
+ id: "other-workflow",
672
+ workflow: "other",
673
+ tags: [
674
+ "singleton:k"
675
+ ]
676
+ }));
677
+ expect2(await claim("other-workflow", "singleton:k", "other")).toBe(true);
678
+ });
486
679
  t("creates a run and reads it back with its JSON input", async () => {
487
680
  await store.createRun(run());
488
681
  const loaded = await store.getRun("r1");
@@ -856,6 +1049,42 @@ function runStateStoreContract(name, makeStore) {
856
1049
  });
857
1050
  expect2((await store.getRun("r1"))?.awaitingDecisionTaskId).toBeUndefined();
858
1051
  });
1052
+ t("saveCheckpointIfPending writes over a pending row only", async () => {
1053
+ if (!store.saveCheckpointIfPending) return;
1054
+ await store.createRun(run());
1055
+ const pending = checkpoint({
1056
+ status: "pending",
1057
+ output: void 0
1058
+ });
1059
+ await store.saveCheckpoint(pending);
1060
+ expect2(await store.saveCheckpointIfPending({
1061
+ ...pending,
1062
+ wakeAt: 5e3
1063
+ })).toBe(true);
1064
+ expect2((await store.getCheckpoint("r1", 0))?.wakeAt).toBe(5e3);
1065
+ const done = {
1066
+ ...pending,
1067
+ status: "completed",
1068
+ output: {
1069
+ ok: true
1070
+ }
1071
+ };
1072
+ expect2(await store.saveCheckpointIfPending(done)).toBe(true);
1073
+ expect2(await store.saveCheckpointIfPending({
1074
+ ...pending,
1075
+ wakeAt: 9e3
1076
+ })).toBe(false);
1077
+ const after = await store.getCheckpoint("r1", 0);
1078
+ expect2(after?.status).toBe("completed");
1079
+ expect2(after?.output).toEqual({
1080
+ ok: true
1081
+ });
1082
+ expect2(await store.saveCheckpointIfPending({
1083
+ ...pending,
1084
+ seq: 9
1085
+ })).toBe(false);
1086
+ expect2(await store.getCheckpoint("r1", 9)).toBeNull();
1087
+ });
859
1088
  t("upserts checkpoints, reads them by (runId, seq), and lists them ordered by seq", async () => {
860
1089
  await store.createRun(run());
861
1090
  await store.saveCheckpoint(checkpoint());
@@ -2439,6 +2668,184 @@ function runStateStoreContract(name, makeStore) {
2439
2668
  expect2((await store.takeSignalWaiter("child:r.child.0"))?.parallelGroup).toBe("gather:0");
2440
2669
  expect2((await store.takeSignalWaiter("child:r.child.9"))?.parallelGroup).toBeUndefined();
2441
2670
  });
2671
+ t("registerChildWaits writes every waiter and placeholder, and upserts on repeat", async () => {
2672
+ expect2(store.registerChildWaits).toBeTypeOf("function");
2673
+ await store.createRun(run({
2674
+ id: "p"
2675
+ }));
2676
+ const entry = /* @__PURE__ */ __name((i, status) => ({
2677
+ waiter: {
2678
+ token: `child:p.all.0.${i}`,
2679
+ runId: "p",
2680
+ seq: i,
2681
+ parallelGroup: "all:0"
2682
+ },
2683
+ checkpoint: checkpoint({
2684
+ runId: "p",
2685
+ seq: i,
2686
+ stepId: `p:${i}`,
2687
+ name: `signal:child:p.all.0.${i}`,
2688
+ kind: "signal",
2689
+ status,
2690
+ parallelGroup: "all:0"
2691
+ })
2692
+ }), "entry");
2693
+ const entries = Array.from({
2694
+ length: 250
2695
+ }, (_, i) => entry(i, "running"));
2696
+ await store.registerChildWaits?.(entries);
2697
+ const waiters = await store.listSignalWaiters("child:p.all.0.");
2698
+ expect2(waiters).toHaveLength(250);
2699
+ expect2(waiters.find((w) => w.token === "child:p.all.0.7")).toEqual(entries[7]?.waiter);
2700
+ const cps = await store.listCheckpoints("p");
2701
+ expect2(cps).toHaveLength(250);
2702
+ expect2(cps[7]).toMatchObject({
2703
+ name: "signal:child:p.all.0.7",
2704
+ status: "running"
2705
+ });
2706
+ expect2(cps[7]?.parallelGroup).toBe("all:0");
2707
+ await store.registerChildWaits?.([
2708
+ entry(7, "completed")
2709
+ ]);
2710
+ expect2((await store.getCheckpoint("p", 7))?.status).toBe("completed");
2711
+ expect2(await store.listCheckpoints("p")).toHaveLength(250);
2712
+ await expect2(store.registerChildWaits?.([])).resolves.toBeUndefined();
2713
+ });
2714
+ t("compactCheckpoints replaces a seq range with one checkpoint, atomically", async () => {
2715
+ expect2(store.compactCheckpoints).toBeTypeOf("function");
2716
+ await store.createRun(run({
2717
+ id: "r"
2718
+ }));
2719
+ await store.createRun(run({
2720
+ id: "other"
2721
+ }));
2722
+ for (let seq = 0; seq < 10; seq += 1) {
2723
+ await store.saveCheckpoint(checkpoint({
2724
+ runId: "r",
2725
+ seq,
2726
+ stepId: `r:${seq}`
2727
+ }));
2728
+ }
2729
+ await store.saveCheckpoint(checkpoint({
2730
+ runId: "other",
2731
+ seq: 3,
2732
+ stepId: "other:3"
2733
+ }));
2734
+ const into = checkpoint({
2735
+ runId: "r",
2736
+ seq: 2,
2737
+ stepId: "r:2",
2738
+ name: "fan:r.all.2.",
2739
+ kind: "signal",
2740
+ output: {
2741
+ from: 0,
2742
+ values: [
2743
+ {
2744
+ n: 1
2745
+ },
2746
+ "two",
2747
+ null
2748
+ ]
2749
+ },
2750
+ parallelGroup: "all:2"
2751
+ });
2752
+ await store.compactCheckpoints?.("r", 2, 7, into);
2753
+ const left = await store.listCheckpoints("r");
2754
+ expect2(left.map((cp) => cp.seq)).toEqual([
2755
+ 0,
2756
+ 1,
2757
+ 2,
2758
+ 8,
2759
+ 9
2760
+ ]);
2761
+ expect2(left[2]).toMatchObject({
2762
+ name: "fan:r.all.2.",
2763
+ parallelGroup: "all:2"
2764
+ });
2765
+ expect2(left[2]?.output).toEqual({
2766
+ from: 0,
2767
+ values: [
2768
+ {
2769
+ n: 1
2770
+ },
2771
+ "two",
2772
+ null
2773
+ ]
2774
+ });
2775
+ expect2(await store.listCheckpoints("other")).toHaveLength(1);
2776
+ });
2777
+ t("listSignalWaitersByRun returns only the given runs' waiters", async () => {
2778
+ expect2(store.listSignalWaitersByRun).toBeTypeOf("function");
2779
+ await store.putSignalWaiter({
2780
+ token: "child:a.all.0.0",
2781
+ runId: "a",
2782
+ seq: 0,
2783
+ parallelGroup: "all:0"
2784
+ });
2785
+ await store.putSignalWaiter({
2786
+ token: "approve-a",
2787
+ runId: "a",
2788
+ seq: 1
2789
+ });
2790
+ await store.putSignalWaiter({
2791
+ token: "approve-b",
2792
+ runId: "b",
2793
+ seq: 0
2794
+ });
2795
+ await store.putSignalWaiter({
2796
+ token: "approve-c",
2797
+ runId: "c",
2798
+ seq: 0
2799
+ });
2800
+ const tokens = /* @__PURE__ */ __name(async (ids2) => (await store.listSignalWaitersByRun?.(ids2) ?? []).map((w) => w.token).sort(), "tokens");
2801
+ expect2(await tokens([
2802
+ "a",
2803
+ "b"
2804
+ ])).toEqual([
2805
+ "approve-a",
2806
+ "approve-b",
2807
+ "child:a.all.0.0"
2808
+ ]);
2809
+ expect2(await tokens([
2810
+ "missing"
2811
+ ])).toEqual([]);
2812
+ expect2(await tokens([])).toEqual([]);
2813
+ expect2((await store.listSignalWaitersByRun?.([
2814
+ "a"
2815
+ ]))?.find((w) => w.seq === 0)).toEqual({
2816
+ token: "child:a.all.0.0",
2817
+ runId: "a",
2818
+ seq: 0,
2819
+ parallelGroup: "all:0"
2820
+ });
2821
+ });
2822
+ t("hasSignalWaiter answers by run and exact token prefix", async () => {
2823
+ expect2(store.hasSignalWaiter).toBeTypeOf("function");
2824
+ const has = /* @__PURE__ */ __name((runId, prefix) => store.hasSignalWaiter?.(runId, prefix), "has");
2825
+ await store.putSignalWaiter({
2826
+ token: "child:p_1.all.3.0",
2827
+ runId: "p_1",
2828
+ seq: 3,
2829
+ parallelGroup: "all:3"
2830
+ });
2831
+ await store.putSignalWaiter({
2832
+ token: "child:p_1.all.4.0.all.0.0",
2833
+ runId: "p_1.all.4.0",
2834
+ seq: 0
2835
+ });
2836
+ await store.putSignalWaiter({
2837
+ token: "child:pX1.all.3.0",
2838
+ runId: "p_1",
2839
+ seq: 9
2840
+ });
2841
+ expect2(await has("p_1", "child:p_1.all.3.")).toBe(true);
2842
+ expect2(await has("p_1", "child:p_1.all.4.")).toBe(false);
2843
+ expect2(await has("other", "child:p_1.all.3.")).toBe(false);
2844
+ expect2(await has("p_1", "child:p%.all.3.")).toBe(false);
2845
+ expect2(await has("p_1", "CHILD:p_1.all.3.")).toBe(false);
2846
+ await store.takeSignalWaiter("child:p_1.all.3.0");
2847
+ expect2(await has("p_1", "child:p_1.all.3.")).toBe(false);
2848
+ });
2442
2849
  t("removeSignalWaiter deletes only the exact (token, runId, seq) row, never a different run's", async () => {
2443
2850
  await store.putSignalWaiter({
2444
2851
  token: "approve-1",