@dudousxd/nestjs-durable-testing 0.14.0 → 0.16.0
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 +14 -0
- package/dist/index.js +1060 -1
- package/dist/index.js.map +1 -1
- package/package.json +11 -2
package/dist/index.js
CHANGED
|
@@ -287,6 +287,19 @@ var DURABLE_CANONICAL_COLUMNS = {
|
|
|
287
287
|
name: "name",
|
|
288
288
|
payload: "payload",
|
|
289
289
|
publishedAt: "published_at"
|
|
290
|
+
},
|
|
291
|
+
// Persisted schedules (`engine.schedules`). `spec`/`state` are engine-owned JSON documents.
|
|
292
|
+
durable_schedules: {
|
|
293
|
+
id: "id",
|
|
294
|
+
namespace: "namespace",
|
|
295
|
+
workflow: "workflow",
|
|
296
|
+
paused: "paused",
|
|
297
|
+
nextFireAt: "next_fire_at",
|
|
298
|
+
tags: "tags",
|
|
299
|
+
spec: "spec",
|
|
300
|
+
state: "state",
|
|
301
|
+
createdAt: "created_at",
|
|
302
|
+
updatedAt: "updated_at"
|
|
290
303
|
}
|
|
291
304
|
};
|
|
292
305
|
function assertDurableColumns(resolve, expected = DURABLE_CANONICAL_COLUMNS) {
|
|
@@ -386,7 +399,7 @@ async function assertReplayable(register, history) {
|
|
|
386
399
|
__name(assertReplayable, "assertReplayable");
|
|
387
400
|
|
|
388
401
|
// src/state-store-conformance.ts
|
|
389
|
-
import { WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
|
|
402
|
+
import { SINGLETON_ADMITTED_TAG, WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
|
|
390
403
|
import { beforeEach, describe as describe2, expect as expect2, it as it2 } from "vitest";
|
|
391
404
|
var StateStoreUnavailableError = class extends Error {
|
|
392
405
|
static {
|
|
@@ -403,6 +416,8 @@ function isUnavailable(err) {
|
|
|
403
416
|
}
|
|
404
417
|
__name(isUnavailable, "isUnavailable");
|
|
405
418
|
var at = /* @__PURE__ */ new Date("2026-06-11T00:00:00.000Z");
|
|
419
|
+
var NOW = Date.parse("2026-06-20T00:00:00.000Z");
|
|
420
|
+
var DAY = 864e5;
|
|
406
421
|
var run = /* @__PURE__ */ __name((over = {}) => ({
|
|
407
422
|
id: "r1",
|
|
408
423
|
workflow: "checkout",
|
|
@@ -434,6 +449,7 @@ var checkpoint = /* @__PURE__ */ __name((over = {}) => ({
|
|
|
434
449
|
function runStateStoreContract(name, makeStore) {
|
|
435
450
|
describe2(`StateStore contract: ${name}`, () => {
|
|
436
451
|
let store;
|
|
452
|
+
let peerStore;
|
|
437
453
|
let cleanup;
|
|
438
454
|
let supportsAsyncTransaction = true;
|
|
439
455
|
let supportsTagFilter = true;
|
|
@@ -443,6 +459,7 @@ function runStateStoreContract(name, makeStore) {
|
|
|
443
459
|
try {
|
|
444
460
|
const ctx = await makeStore();
|
|
445
461
|
store = ctx.store;
|
|
462
|
+
peerStore = ctx.peerStore ?? ctx.store;
|
|
446
463
|
cleanup = ctx.cleanup;
|
|
447
464
|
supportsAsyncTransaction = ctx.supportsAsyncTransaction ?? true;
|
|
448
465
|
supportsTagFilter = ctx.supportsTagFilter ?? true;
|
|
@@ -468,6 +485,197 @@ function runStateStoreContract(name, makeStore) {
|
|
|
468
485
|
await fn();
|
|
469
486
|
});
|
|
470
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
|
+
});
|
|
471
679
|
t("creates a run and reads it back with its JSON input", async () => {
|
|
472
680
|
await store.createRun(run());
|
|
473
681
|
const loaded = await store.getRun("r1");
|
|
@@ -544,6 +752,249 @@ function runStateStoreContract(name, makeStore) {
|
|
|
544
752
|
t("deleteRun is a no-op for a missing run", async () => {
|
|
545
753
|
await expect2(store.deleteRun("nope")).resolves.toBeUndefined();
|
|
546
754
|
});
|
|
755
|
+
t("deleteRuns removes every listed run with its rows and ignores missing ids", async () => {
|
|
756
|
+
await store.createRun(run({
|
|
757
|
+
id: "d1",
|
|
758
|
+
searchAttributes: {
|
|
759
|
+
tier: "pro"
|
|
760
|
+
}
|
|
761
|
+
}));
|
|
762
|
+
await store.createRun(run({
|
|
763
|
+
id: "d2"
|
|
764
|
+
}));
|
|
765
|
+
await store.createRun(run({
|
|
766
|
+
id: "keep"
|
|
767
|
+
}));
|
|
768
|
+
await store.saveCheckpoint(checkpoint({
|
|
769
|
+
runId: "d1",
|
|
770
|
+
stepId: "d1:0"
|
|
771
|
+
}));
|
|
772
|
+
await store.putSignalWaiter({
|
|
773
|
+
token: "approve-d2",
|
|
774
|
+
runId: "d2",
|
|
775
|
+
seq: 0
|
|
776
|
+
});
|
|
777
|
+
expect2(typeof store.deleteRuns).toBe("function");
|
|
778
|
+
await store.deleteRuns?.([
|
|
779
|
+
"d1",
|
|
780
|
+
"d2",
|
|
781
|
+
"ghost"
|
|
782
|
+
]);
|
|
783
|
+
expect2(await store.getRun("d1")).toBeNull();
|
|
784
|
+
expect2(await store.getRun("d2")).toBeNull();
|
|
785
|
+
expect2(await store.getRun("keep")).not.toBeNull();
|
|
786
|
+
expect2(await store.listCheckpoints("d1")).toEqual([]);
|
|
787
|
+
expect2(await store.listSignalWaiters("approve-")).toEqual([]);
|
|
788
|
+
expect2(await store.listRuns({
|
|
789
|
+
attributes: [
|
|
790
|
+
{
|
|
791
|
+
key: "tier",
|
|
792
|
+
op: "eq",
|
|
793
|
+
value: "pro"
|
|
794
|
+
}
|
|
795
|
+
]
|
|
796
|
+
})).toEqual([]);
|
|
797
|
+
await expect2(store.deleteRuns?.([])).resolves.toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
const aged = /* @__PURE__ */ __name((id, ageDays, over = {}) => run({
|
|
800
|
+
id,
|
|
801
|
+
status: "completed",
|
|
802
|
+
createdAt: new Date(NOW - ageDays * DAY),
|
|
803
|
+
updatedAt: new Date(NOW - ageDays * DAY),
|
|
804
|
+
...over
|
|
805
|
+
}), "aged");
|
|
806
|
+
const ids = /* @__PURE__ */ __name(async () => (await store.listRuns({})).map((r) => r.id).sort(), "ids");
|
|
807
|
+
t("pruneTerminalRuns drops terminal runs past maxAge and never touches live ones", async () => {
|
|
808
|
+
await store.createRun(aged("old-done", 10));
|
|
809
|
+
await store.createRun(aged("old-failed", 10, {
|
|
810
|
+
status: "failed"
|
|
811
|
+
}));
|
|
812
|
+
await store.createRun(aged("old-live", 10, {
|
|
813
|
+
status: "suspended"
|
|
814
|
+
}));
|
|
815
|
+
await store.createRun(aged("fresh-done", 1));
|
|
816
|
+
await store.saveCheckpoint(checkpoint({
|
|
817
|
+
runId: "old-done",
|
|
818
|
+
stepId: "old-done:0"
|
|
819
|
+
}));
|
|
820
|
+
expect2(typeof store.pruneTerminalRuns).toBe("function");
|
|
821
|
+
const deleted = await store.pruneTerminalRuns?.({
|
|
822
|
+
statuses: [
|
|
823
|
+
"completed",
|
|
824
|
+
"failed",
|
|
825
|
+
"suspended"
|
|
826
|
+
],
|
|
827
|
+
maxAge: "7d"
|
|
828
|
+
}, NOW, 100);
|
|
829
|
+
expect2(deleted).toBe(2);
|
|
830
|
+
expect2(await ids()).toEqual([
|
|
831
|
+
"fresh-done",
|
|
832
|
+
"old-live"
|
|
833
|
+
]);
|
|
834
|
+
expect2(await store.listCheckpoints("old-done")).toEqual([]);
|
|
835
|
+
});
|
|
836
|
+
t("pruneTerminalRuns keeps only the newest maxCount and drains in batches of limit", async () => {
|
|
837
|
+
for (let i = 1; i <= 5; i++) await store.createRun(aged(`c${i}`, i));
|
|
838
|
+
const policy = {
|
|
839
|
+
statuses: [
|
|
840
|
+
"completed"
|
|
841
|
+
],
|
|
842
|
+
maxCount: 2
|
|
843
|
+
};
|
|
844
|
+
expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(2);
|
|
845
|
+
expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(1);
|
|
846
|
+
expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(0);
|
|
847
|
+
expect2(await ids()).toEqual([
|
|
848
|
+
"c1",
|
|
849
|
+
"c2"
|
|
850
|
+
]);
|
|
851
|
+
});
|
|
852
|
+
t("a scoped retention policy only prunes (and counts) the runs its scope matches", async () => {
|
|
853
|
+
await store.createRun(aged("acme-old", 10, {
|
|
854
|
+
namespace: "acme"
|
|
855
|
+
}));
|
|
856
|
+
await store.createRun(aged("acme-new", 1, {
|
|
857
|
+
namespace: "acme"
|
|
858
|
+
}));
|
|
859
|
+
await store.createRun(aged("beta-old", 10, {
|
|
860
|
+
namespace: "beta"
|
|
861
|
+
}));
|
|
862
|
+
await store.createRun(aged("report-old", 10, {
|
|
863
|
+
workflow: "report"
|
|
864
|
+
}));
|
|
865
|
+
await store.pruneTerminalRuns?.({
|
|
866
|
+
statuses: [
|
|
867
|
+
"completed"
|
|
868
|
+
],
|
|
869
|
+
maxAge: "7d",
|
|
870
|
+
scope: {
|
|
871
|
+
namespace: "acme"
|
|
872
|
+
}
|
|
873
|
+
}, NOW, 100);
|
|
874
|
+
expect2(await ids()).toEqual([
|
|
875
|
+
"acme-new",
|
|
876
|
+
"beta-old",
|
|
877
|
+
"report-old"
|
|
878
|
+
]);
|
|
879
|
+
await store.pruneTerminalRuns?.({
|
|
880
|
+
statuses: [
|
|
881
|
+
"completed"
|
|
882
|
+
],
|
|
883
|
+
maxAge: "7d",
|
|
884
|
+
scope: {
|
|
885
|
+
workflows: [
|
|
886
|
+
"report"
|
|
887
|
+
]
|
|
888
|
+
}
|
|
889
|
+
}, NOW, 100);
|
|
890
|
+
expect2(await ids()).toEqual([
|
|
891
|
+
"acme-new",
|
|
892
|
+
"beta-old"
|
|
893
|
+
]);
|
|
894
|
+
await store.createRun(aged("acme-mid", 3, {
|
|
895
|
+
namespace: "acme"
|
|
896
|
+
}));
|
|
897
|
+
await store.pruneTerminalRuns?.({
|
|
898
|
+
statuses: [
|
|
899
|
+
"completed"
|
|
900
|
+
],
|
|
901
|
+
maxCount: 1,
|
|
902
|
+
scope: {
|
|
903
|
+
namespace: "acme"
|
|
904
|
+
}
|
|
905
|
+
}, NOW, 100);
|
|
906
|
+
expect2(await ids()).toEqual([
|
|
907
|
+
"acme-new",
|
|
908
|
+
"beta-old"
|
|
909
|
+
]);
|
|
910
|
+
});
|
|
911
|
+
t("a retention scope can select by tag and by search attribute", async () => {
|
|
912
|
+
if (!supportsTagFilter) return;
|
|
913
|
+
await store.createRun(aged("chat-old", 10, {
|
|
914
|
+
tags: [
|
|
915
|
+
"chat",
|
|
916
|
+
"tenant:t1"
|
|
917
|
+
]
|
|
918
|
+
}));
|
|
919
|
+
await store.createRun(aged("chatty-old", 10, {
|
|
920
|
+
tags: [
|
|
921
|
+
"chatty"
|
|
922
|
+
]
|
|
923
|
+
}));
|
|
924
|
+
await store.createRun(aged("attr-old", 10, {
|
|
925
|
+
searchAttributes: {
|
|
926
|
+
tenantId: "t2"
|
|
927
|
+
}
|
|
928
|
+
}));
|
|
929
|
+
await store.createRun(aged("attr-other", 10, {
|
|
930
|
+
searchAttributes: {
|
|
931
|
+
tenantId: "t3"
|
|
932
|
+
}
|
|
933
|
+
}));
|
|
934
|
+
await store.pruneTerminalRuns?.({
|
|
935
|
+
statuses: [
|
|
936
|
+
"completed"
|
|
937
|
+
],
|
|
938
|
+
maxAge: "7d",
|
|
939
|
+
scope: {
|
|
940
|
+
tags: [
|
|
941
|
+
"chat"
|
|
942
|
+
]
|
|
943
|
+
}
|
|
944
|
+
}, NOW, 100);
|
|
945
|
+
expect2(await ids()).toEqual([
|
|
946
|
+
"attr-old",
|
|
947
|
+
"attr-other",
|
|
948
|
+
"chatty-old"
|
|
949
|
+
]);
|
|
950
|
+
await store.pruneTerminalRuns?.({
|
|
951
|
+
statuses: [
|
|
952
|
+
"completed"
|
|
953
|
+
],
|
|
954
|
+
maxAge: "7d",
|
|
955
|
+
scope: {
|
|
956
|
+
attributes: [
|
|
957
|
+
{
|
|
958
|
+
key: "tenantId",
|
|
959
|
+
op: "eq",
|
|
960
|
+
value: "t2"
|
|
961
|
+
}
|
|
962
|
+
]
|
|
963
|
+
}
|
|
964
|
+
}, NOW, 100);
|
|
965
|
+
expect2(await ids()).toEqual([
|
|
966
|
+
"attr-other",
|
|
967
|
+
"chatty-old"
|
|
968
|
+
]);
|
|
969
|
+
});
|
|
970
|
+
t("engine.purgeRuns deletes a scope with its child subtrees and cancels live runs", async () => {
|
|
971
|
+
const engine = new WorkflowEngine3({
|
|
972
|
+
store
|
|
973
|
+
});
|
|
974
|
+
engine.register("p-child", "1", async (ctx) => ctx.waitForSignal("never"));
|
|
975
|
+
engine.register("p-root", "1", async (ctx) => {
|
|
976
|
+
await ctx.startChild("p-child", {}, "pc");
|
|
977
|
+
return ctx.waitForSignal("never");
|
|
978
|
+
});
|
|
979
|
+
engine.register("p-other", "1", async () => "done");
|
|
980
|
+
await engine.start("p-root", {}, "pr");
|
|
981
|
+
await engine.start("p-other", {}, "po");
|
|
982
|
+
expect2((await engine.waitForRun("pc", {
|
|
983
|
+
timeoutMs: 2e4
|
|
984
|
+
})).status).toBe("suspended");
|
|
985
|
+
expect2((await engine.waitForRun("po", {
|
|
986
|
+
timeoutMs: 2e4
|
|
987
|
+
})).status).toBe("completed");
|
|
988
|
+
await expect2(engine.purgeRuns({})).rejects.toThrow(/empty scope/);
|
|
989
|
+
expect2(await engine.purgeRuns({
|
|
990
|
+
workflows: [
|
|
991
|
+
"p-root"
|
|
992
|
+
]
|
|
993
|
+
})).toBe(2);
|
|
994
|
+
expect2(await store.getRun("pr")).toBeNull();
|
|
995
|
+
expect2(await store.getRun("pc")).toBeNull();
|
|
996
|
+
expect2(await store.getRun("po")).not.toBeNull();
|
|
997
|
+
});
|
|
547
998
|
t("updateRun maps every patchable field \u2014 clears error and patches tags/lockedBy/input/timers", async () => {
|
|
548
999
|
await store.createRun(run({
|
|
549
1000
|
status: "failed",
|
|
@@ -1427,6 +1878,149 @@ function runStateStoreContract(name, makeStore) {
|
|
|
1427
1878
|
"pro"
|
|
1428
1879
|
]);
|
|
1429
1880
|
});
|
|
1881
|
+
t("searches values server-side, BEFORE the bound", async () => {
|
|
1882
|
+
if (!store.runValueFacets) return;
|
|
1883
|
+
await store.createRun(run({
|
|
1884
|
+
id: "a",
|
|
1885
|
+
namespace: "acme-prod"
|
|
1886
|
+
}));
|
|
1887
|
+
await store.createRun(run({
|
|
1888
|
+
id: "b",
|
|
1889
|
+
namespace: "acme-staging"
|
|
1890
|
+
}));
|
|
1891
|
+
await store.createRun(run({
|
|
1892
|
+
id: "c",
|
|
1893
|
+
namespace: "globex"
|
|
1894
|
+
}));
|
|
1895
|
+
await store.createRun(run({
|
|
1896
|
+
id: "d",
|
|
1897
|
+
namespace: "globex"
|
|
1898
|
+
}));
|
|
1899
|
+
const matches = await store.runValueFacets({
|
|
1900
|
+
field: "namespace"
|
|
1901
|
+
}, {}, {
|
|
1902
|
+
search: "acme"
|
|
1903
|
+
});
|
|
1904
|
+
expect2(matches.map((row) => row.value).sort()).toEqual([
|
|
1905
|
+
"acme-prod",
|
|
1906
|
+
"acme-staging"
|
|
1907
|
+
]);
|
|
1908
|
+
expect2((await store.runValueFacets({
|
|
1909
|
+
field: "namespace"
|
|
1910
|
+
}, {}, {
|
|
1911
|
+
limit: 1
|
|
1912
|
+
})).map((r) => r.value)).toEqual([
|
|
1913
|
+
"globex"
|
|
1914
|
+
]);
|
|
1915
|
+
expect2((await store.runValueFacets({
|
|
1916
|
+
field: "namespace"
|
|
1917
|
+
}, {}, {
|
|
1918
|
+
limit: 1,
|
|
1919
|
+
search: "staging"
|
|
1920
|
+
})).map((r) => r.value)).toEqual([
|
|
1921
|
+
"acme-staging"
|
|
1922
|
+
]);
|
|
1923
|
+
});
|
|
1924
|
+
t("pages values in a stable order, so page two continues page one", async () => {
|
|
1925
|
+
if (!store.runValueFacets) return;
|
|
1926
|
+
await store.createRun(run({
|
|
1927
|
+
id: "a",
|
|
1928
|
+
namespace: "one"
|
|
1929
|
+
}));
|
|
1930
|
+
await store.createRun(run({
|
|
1931
|
+
id: "b",
|
|
1932
|
+
namespace: "one"
|
|
1933
|
+
}));
|
|
1934
|
+
await store.createRun(run({
|
|
1935
|
+
id: "c",
|
|
1936
|
+
namespace: "one"
|
|
1937
|
+
}));
|
|
1938
|
+
await store.createRun(run({
|
|
1939
|
+
id: "d",
|
|
1940
|
+
namespace: "two"
|
|
1941
|
+
}));
|
|
1942
|
+
await store.createRun(run({
|
|
1943
|
+
id: "e",
|
|
1944
|
+
namespace: "two"
|
|
1945
|
+
}));
|
|
1946
|
+
await store.createRun(run({
|
|
1947
|
+
id: "f",
|
|
1948
|
+
namespace: "three"
|
|
1949
|
+
}));
|
|
1950
|
+
const first = await store.runValueFacets({
|
|
1951
|
+
field: "namespace"
|
|
1952
|
+
}, {}, {
|
|
1953
|
+
limit: 2
|
|
1954
|
+
});
|
|
1955
|
+
const second = await store.runValueFacets({
|
|
1956
|
+
field: "namespace"
|
|
1957
|
+
}, {}, {
|
|
1958
|
+
limit: 2,
|
|
1959
|
+
offset: 2
|
|
1960
|
+
});
|
|
1961
|
+
expect2(first).toEqual([
|
|
1962
|
+
{
|
|
1963
|
+
value: "one",
|
|
1964
|
+
count: 3
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
value: "two",
|
|
1968
|
+
count: 2
|
|
1969
|
+
}
|
|
1970
|
+
]);
|
|
1971
|
+
expect2(second).toEqual([
|
|
1972
|
+
{
|
|
1973
|
+
value: "three",
|
|
1974
|
+
count: 1
|
|
1975
|
+
}
|
|
1976
|
+
]);
|
|
1977
|
+
expect2(new Set([
|
|
1978
|
+
...first,
|
|
1979
|
+
...second
|
|
1980
|
+
].map((r) => r.value)).size).toBe(3);
|
|
1981
|
+
});
|
|
1982
|
+
t("searches and pages the tag axis too, where the values actually pile up", async () => {
|
|
1983
|
+
if (!store.runValueFacets) return;
|
|
1984
|
+
if (!supportsTagFilter) return;
|
|
1985
|
+
await store.createRun(run({
|
|
1986
|
+
id: "a",
|
|
1987
|
+
tags: [
|
|
1988
|
+
"type:mvr",
|
|
1989
|
+
"etl"
|
|
1990
|
+
]
|
|
1991
|
+
}));
|
|
1992
|
+
await store.createRun(run({
|
|
1993
|
+
id: "b",
|
|
1994
|
+
tags: [
|
|
1995
|
+
"type:mel",
|
|
1996
|
+
"etl"
|
|
1997
|
+
]
|
|
1998
|
+
}));
|
|
1999
|
+
await store.createRun(run({
|
|
2000
|
+
id: "c",
|
|
2001
|
+
tags: [
|
|
2002
|
+
"etl"
|
|
2003
|
+
]
|
|
2004
|
+
}));
|
|
2005
|
+
expect2((await store.runValueFacets({
|
|
2006
|
+
field: "tag"
|
|
2007
|
+
}, {}, {
|
|
2008
|
+
search: "type:"
|
|
2009
|
+
})).map((r) => r.value).sort()).toEqual([
|
|
2010
|
+
"type:mel",
|
|
2011
|
+
"type:mvr"
|
|
2012
|
+
]);
|
|
2013
|
+
expect2(await store.runValueFacets({
|
|
2014
|
+
field: "tag"
|
|
2015
|
+
}, {}, {
|
|
2016
|
+
limit: 1
|
|
2017
|
+
})).toEqual([
|
|
2018
|
+
{
|
|
2019
|
+
value: "etl",
|
|
2020
|
+
count: 3
|
|
2021
|
+
}
|
|
2022
|
+
]);
|
|
2023
|
+
});
|
|
1430
2024
|
t("bounds the values it returns, keeping the most common ones", async () => {
|
|
1431
2025
|
if (!store.runValueFacets) return;
|
|
1432
2026
|
await store.createRun(run({
|
|
@@ -1452,6 +2046,293 @@ function runStateStoreContract(name, makeStore) {
|
|
|
1452
2046
|
}
|
|
1453
2047
|
]);
|
|
1454
2048
|
});
|
|
2049
|
+
t("countRuns counts the same set listRuns returns, without the paging", async () => {
|
|
2050
|
+
expect2(typeof store.countRuns).toBe("function");
|
|
2051
|
+
await store.createRun(run({
|
|
2052
|
+
id: "n1",
|
|
2053
|
+
status: "running",
|
|
2054
|
+
namespace: "a",
|
|
2055
|
+
tags: [
|
|
2056
|
+
"q"
|
|
2057
|
+
]
|
|
2058
|
+
}));
|
|
2059
|
+
await store.createRun(run({
|
|
2060
|
+
id: "n2",
|
|
2061
|
+
status: "pending",
|
|
2062
|
+
namespace: "a"
|
|
2063
|
+
}));
|
|
2064
|
+
await store.createRun(run({
|
|
2065
|
+
id: "n3",
|
|
2066
|
+
status: "completed",
|
|
2067
|
+
namespace: "a",
|
|
2068
|
+
tags: [
|
|
2069
|
+
"q"
|
|
2070
|
+
]
|
|
2071
|
+
}));
|
|
2072
|
+
await store.createRun(run({
|
|
2073
|
+
id: "n4",
|
|
2074
|
+
status: "running",
|
|
2075
|
+
namespace: "b",
|
|
2076
|
+
tags: [
|
|
2077
|
+
"q"
|
|
2078
|
+
]
|
|
2079
|
+
}));
|
|
2080
|
+
expect2(await store.countRuns?.({})).toBe(4);
|
|
2081
|
+
expect2(await store.countRuns?.({
|
|
2082
|
+
namespace: "a"
|
|
2083
|
+
})).toBe(3);
|
|
2084
|
+
expect2(await store.countRuns?.({
|
|
2085
|
+
namespace: "a",
|
|
2086
|
+
statuses: [
|
|
2087
|
+
"pending",
|
|
2088
|
+
"running"
|
|
2089
|
+
]
|
|
2090
|
+
})).toBe(2);
|
|
2091
|
+
expect2(await store.countRuns?.({
|
|
2092
|
+
statuses: []
|
|
2093
|
+
})).toBe(0);
|
|
2094
|
+
if (supportsTagFilter) {
|
|
2095
|
+
expect2(await store.countRuns?.({
|
|
2096
|
+
tag: "q",
|
|
2097
|
+
statuses: [
|
|
2098
|
+
"running",
|
|
2099
|
+
"pending"
|
|
2100
|
+
]
|
|
2101
|
+
})).toBe(2);
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
t("engine rejects a start over its concurrency quota and frees the slot once a run settles", async () => {
|
|
2105
|
+
if (!supportsTagFilter) return;
|
|
2106
|
+
const engine = new WorkflowEngine3({
|
|
2107
|
+
store
|
|
2108
|
+
});
|
|
2109
|
+
engine.register("q-wait", "1", async (ctx) => ctx.waitForSignal("never"));
|
|
2110
|
+
const quota = {
|
|
2111
|
+
key: "tenant:a",
|
|
2112
|
+
limit: 1,
|
|
2113
|
+
countStatuses: [
|
|
2114
|
+
"pending",
|
|
2115
|
+
"running",
|
|
2116
|
+
"suspended"
|
|
2117
|
+
]
|
|
2118
|
+
};
|
|
2119
|
+
await engine.start("q-wait", {}, "q1", {
|
|
2120
|
+
concurrency: quota
|
|
2121
|
+
});
|
|
2122
|
+
expect2((await engine.waitForRun("q1", {
|
|
2123
|
+
timeoutMs: 2e4
|
|
2124
|
+
})).status).toBe("suspended");
|
|
2125
|
+
await expect2(engine.start("q-wait", {}, "q2", {
|
|
2126
|
+
concurrency: quota
|
|
2127
|
+
})).rejects.toThrow(/concurrency limit/);
|
|
2128
|
+
expect2(await store.getRun("q2")).toBeNull();
|
|
2129
|
+
await engine.start("q-wait", {}, "q3", {
|
|
2130
|
+
concurrency: {
|
|
2131
|
+
...quota,
|
|
2132
|
+
key: "tenant:b"
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
expect2((await engine.waitForRun("q1", {
|
|
2136
|
+
timeoutMs: 2e4
|
|
2137
|
+
})).status).toBe("suspended");
|
|
2138
|
+
await engine.cancel("q1");
|
|
2139
|
+
expect2((await store.getRun("q1"))?.status).toBe("cancelled");
|
|
2140
|
+
await engine.start("q-wait", {}, "q4", {
|
|
2141
|
+
concurrency: quota
|
|
2142
|
+
});
|
|
2143
|
+
expect2(await store.getRun("q4")).not.toBeNull();
|
|
2144
|
+
for (const id of [
|
|
2145
|
+
"q3",
|
|
2146
|
+
"q4"
|
|
2147
|
+
]) await engine.waitForRun(id, {
|
|
2148
|
+
timeoutMs: 2e4
|
|
2149
|
+
});
|
|
2150
|
+
await engine.drain();
|
|
2151
|
+
});
|
|
2152
|
+
const schedule = /* @__PURE__ */ __name((over = {}) => ({
|
|
2153
|
+
id: "s1",
|
|
2154
|
+
namespace: "default",
|
|
2155
|
+
workflow: "digest",
|
|
2156
|
+
paused: false,
|
|
2157
|
+
nextFireAt: NOW + 6e4,
|
|
2158
|
+
tags: [
|
|
2159
|
+
"tenant:a"
|
|
2160
|
+
],
|
|
2161
|
+
spec: {
|
|
2162
|
+
v: 1,
|
|
2163
|
+
cron: "0 9 * * *",
|
|
2164
|
+
input: {
|
|
2165
|
+
userId: "u1",
|
|
2166
|
+
deep: [
|
|
2167
|
+
1,
|
|
2168
|
+
{
|
|
2169
|
+
x: true
|
|
2170
|
+
}
|
|
2171
|
+
]
|
|
2172
|
+
}
|
|
2173
|
+
},
|
|
2174
|
+
state: {
|
|
2175
|
+
window: NOW + 6e4
|
|
2176
|
+
},
|
|
2177
|
+
createdAt: at,
|
|
2178
|
+
updatedAt: at,
|
|
2179
|
+
...over
|
|
2180
|
+
}), "schedule");
|
|
2181
|
+
t("saves, reads back, replaces and deletes a schedule", async () => {
|
|
2182
|
+
expect2(typeof store.saveSchedule).toBe("function");
|
|
2183
|
+
await store.saveSchedule?.(schedule());
|
|
2184
|
+
const loaded = await store.getSchedule?.("s1");
|
|
2185
|
+
expect2(loaded).toMatchObject({
|
|
2186
|
+
id: "s1",
|
|
2187
|
+
namespace: "default",
|
|
2188
|
+
workflow: "digest",
|
|
2189
|
+
paused: false,
|
|
2190
|
+
nextFireAt: NOW + 6e4,
|
|
2191
|
+
tags: [
|
|
2192
|
+
"tenant:a"
|
|
2193
|
+
],
|
|
2194
|
+
spec: {
|
|
2195
|
+
v: 1,
|
|
2196
|
+
cron: "0 9 * * *",
|
|
2197
|
+
input: {
|
|
2198
|
+
userId: "u1",
|
|
2199
|
+
deep: [
|
|
2200
|
+
1,
|
|
2201
|
+
{
|
|
2202
|
+
x: true
|
|
2203
|
+
}
|
|
2204
|
+
]
|
|
2205
|
+
}
|
|
2206
|
+
},
|
|
2207
|
+
state: {
|
|
2208
|
+
window: NOW + 6e4
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
expect2(loaded?.createdAt.getTime()).toBe(at.getTime());
|
|
2212
|
+
await store.saveSchedule?.(schedule({
|
|
2213
|
+
workflow: "digest2",
|
|
2214
|
+
nextFireAt: null,
|
|
2215
|
+
tags: void 0
|
|
2216
|
+
}));
|
|
2217
|
+
const replaced = await store.getSchedule?.("s1");
|
|
2218
|
+
expect2(replaced?.workflow).toBe("digest2");
|
|
2219
|
+
expect2(replaced?.nextFireAt).toBeNull();
|
|
2220
|
+
expect2(replaced?.tags ?? []).toEqual([]);
|
|
2221
|
+
expect2(await store.getSchedule?.("nope")).toBeNull();
|
|
2222
|
+
expect2(await store.deleteSchedule?.("s1")).toBe(true);
|
|
2223
|
+
expect2(await store.deleteSchedule?.("s1")).toBe(false);
|
|
2224
|
+
expect2(await store.getSchedule?.("s1")).toBeNull();
|
|
2225
|
+
});
|
|
2226
|
+
t("updateSchedule patches, and compare-and-sets on nextFireAt", async () => {
|
|
2227
|
+
await store.saveSchedule?.(schedule());
|
|
2228
|
+
expect2(await store.updateSchedule?.("s1", {
|
|
2229
|
+
paused: true
|
|
2230
|
+
})).toBe(true);
|
|
2231
|
+
expect2((await store.getSchedule?.("s1"))?.paused).toBe(true);
|
|
2232
|
+
const next = {
|
|
2233
|
+
nextFireAt: NOW + 12e4,
|
|
2234
|
+
state: {
|
|
2235
|
+
window: NOW + 12e4,
|
|
2236
|
+
fires: 1
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
2239
|
+
expect2(await store.updateSchedule?.("s1", next, NOW + 6e4)).toBe(true);
|
|
2240
|
+
expect2(await store.updateSchedule?.("s1", next, NOW + 6e4)).toBe(false);
|
|
2241
|
+
const after = await store.getSchedule?.("s1");
|
|
2242
|
+
expect2(after?.nextFireAt).toBe(NOW + 12e4);
|
|
2243
|
+
expect2(after?.state).toEqual({
|
|
2244
|
+
window: NOW + 12e4,
|
|
2245
|
+
fires: 1
|
|
2246
|
+
});
|
|
2247
|
+
await store.updateSchedule?.("s1", {
|
|
2248
|
+
nextFireAt: null
|
|
2249
|
+
});
|
|
2250
|
+
expect2(await store.updateSchedule?.("s1", {
|
|
2251
|
+
nextFireAt: NOW
|
|
2252
|
+
}, NOW + 1)).toBe(false);
|
|
2253
|
+
expect2(await store.updateSchedule?.("s1", {
|
|
2254
|
+
nextFireAt: NOW
|
|
2255
|
+
}, null)).toBe(true);
|
|
2256
|
+
expect2(await store.updateSchedule?.("ghost", {
|
|
2257
|
+
paused: true
|
|
2258
|
+
})).toBe(false);
|
|
2259
|
+
});
|
|
2260
|
+
t('lists schedules by namespace/workflow/tag/paused and answers "due by"', async () => {
|
|
2261
|
+
await store.saveSchedule?.(schedule({
|
|
2262
|
+
id: "a",
|
|
2263
|
+
nextFireAt: NOW - 10
|
|
2264
|
+
}));
|
|
2265
|
+
await store.saveSchedule?.(schedule({
|
|
2266
|
+
id: "b",
|
|
2267
|
+
nextFireAt: NOW - 20,
|
|
2268
|
+
namespace: "x"
|
|
2269
|
+
}));
|
|
2270
|
+
await store.saveSchedule?.(schedule({
|
|
2271
|
+
id: "c",
|
|
2272
|
+
nextFireAt: NOW - 30,
|
|
2273
|
+
paused: true
|
|
2274
|
+
}));
|
|
2275
|
+
await store.saveSchedule?.(schedule({
|
|
2276
|
+
id: "d",
|
|
2277
|
+
nextFireAt: NOW + 10,
|
|
2278
|
+
workflow: "other"
|
|
2279
|
+
}));
|
|
2280
|
+
await store.saveSchedule?.(schedule({
|
|
2281
|
+
id: "e",
|
|
2282
|
+
nextFireAt: null,
|
|
2283
|
+
tags: [
|
|
2284
|
+
"tenant:b"
|
|
2285
|
+
]
|
|
2286
|
+
}));
|
|
2287
|
+
const ids2 = /* @__PURE__ */ __name(async (q) => (await store.listSchedules?.(q) ?? []).map((r) => r.id), "ids");
|
|
2288
|
+
expect2(await ids2({})).toEqual([
|
|
2289
|
+
"c",
|
|
2290
|
+
"b",
|
|
2291
|
+
"a",
|
|
2292
|
+
"d",
|
|
2293
|
+
"e"
|
|
2294
|
+
]);
|
|
2295
|
+
expect2(await ids2({
|
|
2296
|
+
dueBy: NOW
|
|
2297
|
+
})).toEqual([
|
|
2298
|
+
"b",
|
|
2299
|
+
"a"
|
|
2300
|
+
]);
|
|
2301
|
+
expect2(await ids2({
|
|
2302
|
+
dueBy: NOW,
|
|
2303
|
+
namespace: "default"
|
|
2304
|
+
})).toEqual([
|
|
2305
|
+
"a"
|
|
2306
|
+
]);
|
|
2307
|
+
expect2(await ids2({
|
|
2308
|
+
dueBy: NOW,
|
|
2309
|
+
limit: 1
|
|
2310
|
+
})).toEqual([
|
|
2311
|
+
"b"
|
|
2312
|
+
]);
|
|
2313
|
+
expect2(await ids2({
|
|
2314
|
+
workflow: "other"
|
|
2315
|
+
})).toEqual([
|
|
2316
|
+
"d"
|
|
2317
|
+
]);
|
|
2318
|
+
expect2(await ids2({
|
|
2319
|
+
paused: true
|
|
2320
|
+
})).toEqual([
|
|
2321
|
+
"c"
|
|
2322
|
+
]);
|
|
2323
|
+
if (supportsTagFilter) expect2(await ids2({
|
|
2324
|
+
tag: "tenant:b"
|
|
2325
|
+
})).toEqual([
|
|
2326
|
+
"e"
|
|
2327
|
+
]);
|
|
2328
|
+
expect2(await ids2({
|
|
2329
|
+
limit: 2,
|
|
2330
|
+
offset: 1
|
|
2331
|
+
})).toEqual([
|
|
2332
|
+
"b",
|
|
2333
|
+
"a"
|
|
2334
|
+
]);
|
|
2335
|
+
});
|
|
1455
2336
|
t("orders listRuns newest-first and paginates with limit/offset", async () => {
|
|
1456
2337
|
await store.createRun(run({
|
|
1457
2338
|
id: "old",
|
|
@@ -1751,6 +2632,184 @@ function runStateStoreContract(name, makeStore) {
|
|
|
1751
2632
|
expect2((await store.takeSignalWaiter("child:r.child.0"))?.parallelGroup).toBe("gather:0");
|
|
1752
2633
|
expect2((await store.takeSignalWaiter("child:r.child.9"))?.parallelGroup).toBeUndefined();
|
|
1753
2634
|
});
|
|
2635
|
+
t("registerChildWaits writes every waiter and placeholder, and upserts on repeat", async () => {
|
|
2636
|
+
expect2(store.registerChildWaits).toBeTypeOf("function");
|
|
2637
|
+
await store.createRun(run({
|
|
2638
|
+
id: "p"
|
|
2639
|
+
}));
|
|
2640
|
+
const entry = /* @__PURE__ */ __name((i, status) => ({
|
|
2641
|
+
waiter: {
|
|
2642
|
+
token: `child:p.all.0.${i}`,
|
|
2643
|
+
runId: "p",
|
|
2644
|
+
seq: i,
|
|
2645
|
+
parallelGroup: "all:0"
|
|
2646
|
+
},
|
|
2647
|
+
checkpoint: checkpoint({
|
|
2648
|
+
runId: "p",
|
|
2649
|
+
seq: i,
|
|
2650
|
+
stepId: `p:${i}`,
|
|
2651
|
+
name: `signal:child:p.all.0.${i}`,
|
|
2652
|
+
kind: "signal",
|
|
2653
|
+
status,
|
|
2654
|
+
parallelGroup: "all:0"
|
|
2655
|
+
})
|
|
2656
|
+
}), "entry");
|
|
2657
|
+
const entries = Array.from({
|
|
2658
|
+
length: 250
|
|
2659
|
+
}, (_, i) => entry(i, "running"));
|
|
2660
|
+
await store.registerChildWaits?.(entries);
|
|
2661
|
+
const waiters = await store.listSignalWaiters("child:p.all.0.");
|
|
2662
|
+
expect2(waiters).toHaveLength(250);
|
|
2663
|
+
expect2(waiters.find((w) => w.token === "child:p.all.0.7")).toEqual(entries[7]?.waiter);
|
|
2664
|
+
const cps = await store.listCheckpoints("p");
|
|
2665
|
+
expect2(cps).toHaveLength(250);
|
|
2666
|
+
expect2(cps[7]).toMatchObject({
|
|
2667
|
+
name: "signal:child:p.all.0.7",
|
|
2668
|
+
status: "running"
|
|
2669
|
+
});
|
|
2670
|
+
expect2(cps[7]?.parallelGroup).toBe("all:0");
|
|
2671
|
+
await store.registerChildWaits?.([
|
|
2672
|
+
entry(7, "completed")
|
|
2673
|
+
]);
|
|
2674
|
+
expect2((await store.getCheckpoint("p", 7))?.status).toBe("completed");
|
|
2675
|
+
expect2(await store.listCheckpoints("p")).toHaveLength(250);
|
|
2676
|
+
await expect2(store.registerChildWaits?.([])).resolves.toBeUndefined();
|
|
2677
|
+
});
|
|
2678
|
+
t("compactCheckpoints replaces a seq range with one checkpoint, atomically", async () => {
|
|
2679
|
+
expect2(store.compactCheckpoints).toBeTypeOf("function");
|
|
2680
|
+
await store.createRun(run({
|
|
2681
|
+
id: "r"
|
|
2682
|
+
}));
|
|
2683
|
+
await store.createRun(run({
|
|
2684
|
+
id: "other"
|
|
2685
|
+
}));
|
|
2686
|
+
for (let seq = 0; seq < 10; seq += 1) {
|
|
2687
|
+
await store.saveCheckpoint(checkpoint({
|
|
2688
|
+
runId: "r",
|
|
2689
|
+
seq,
|
|
2690
|
+
stepId: `r:${seq}`
|
|
2691
|
+
}));
|
|
2692
|
+
}
|
|
2693
|
+
await store.saveCheckpoint(checkpoint({
|
|
2694
|
+
runId: "other",
|
|
2695
|
+
seq: 3,
|
|
2696
|
+
stepId: "other:3"
|
|
2697
|
+
}));
|
|
2698
|
+
const into = checkpoint({
|
|
2699
|
+
runId: "r",
|
|
2700
|
+
seq: 2,
|
|
2701
|
+
stepId: "r:2",
|
|
2702
|
+
name: "fan:r.all.2.",
|
|
2703
|
+
kind: "signal",
|
|
2704
|
+
output: {
|
|
2705
|
+
from: 0,
|
|
2706
|
+
values: [
|
|
2707
|
+
{
|
|
2708
|
+
n: 1
|
|
2709
|
+
},
|
|
2710
|
+
"two",
|
|
2711
|
+
null
|
|
2712
|
+
]
|
|
2713
|
+
},
|
|
2714
|
+
parallelGroup: "all:2"
|
|
2715
|
+
});
|
|
2716
|
+
await store.compactCheckpoints?.("r", 2, 7, into);
|
|
2717
|
+
const left = await store.listCheckpoints("r");
|
|
2718
|
+
expect2(left.map((cp) => cp.seq)).toEqual([
|
|
2719
|
+
0,
|
|
2720
|
+
1,
|
|
2721
|
+
2,
|
|
2722
|
+
8,
|
|
2723
|
+
9
|
|
2724
|
+
]);
|
|
2725
|
+
expect2(left[2]).toMatchObject({
|
|
2726
|
+
name: "fan:r.all.2.",
|
|
2727
|
+
parallelGroup: "all:2"
|
|
2728
|
+
});
|
|
2729
|
+
expect2(left[2]?.output).toEqual({
|
|
2730
|
+
from: 0,
|
|
2731
|
+
values: [
|
|
2732
|
+
{
|
|
2733
|
+
n: 1
|
|
2734
|
+
},
|
|
2735
|
+
"two",
|
|
2736
|
+
null
|
|
2737
|
+
]
|
|
2738
|
+
});
|
|
2739
|
+
expect2(await store.listCheckpoints("other")).toHaveLength(1);
|
|
2740
|
+
});
|
|
2741
|
+
t("listSignalWaitersByRun returns only the given runs' waiters", async () => {
|
|
2742
|
+
expect2(store.listSignalWaitersByRun).toBeTypeOf("function");
|
|
2743
|
+
await store.putSignalWaiter({
|
|
2744
|
+
token: "child:a.all.0.0",
|
|
2745
|
+
runId: "a",
|
|
2746
|
+
seq: 0,
|
|
2747
|
+
parallelGroup: "all:0"
|
|
2748
|
+
});
|
|
2749
|
+
await store.putSignalWaiter({
|
|
2750
|
+
token: "approve-a",
|
|
2751
|
+
runId: "a",
|
|
2752
|
+
seq: 1
|
|
2753
|
+
});
|
|
2754
|
+
await store.putSignalWaiter({
|
|
2755
|
+
token: "approve-b",
|
|
2756
|
+
runId: "b",
|
|
2757
|
+
seq: 0
|
|
2758
|
+
});
|
|
2759
|
+
await store.putSignalWaiter({
|
|
2760
|
+
token: "approve-c",
|
|
2761
|
+
runId: "c",
|
|
2762
|
+
seq: 0
|
|
2763
|
+
});
|
|
2764
|
+
const tokens = /* @__PURE__ */ __name(async (ids2) => (await store.listSignalWaitersByRun?.(ids2) ?? []).map((w) => w.token).sort(), "tokens");
|
|
2765
|
+
expect2(await tokens([
|
|
2766
|
+
"a",
|
|
2767
|
+
"b"
|
|
2768
|
+
])).toEqual([
|
|
2769
|
+
"approve-a",
|
|
2770
|
+
"approve-b",
|
|
2771
|
+
"child:a.all.0.0"
|
|
2772
|
+
]);
|
|
2773
|
+
expect2(await tokens([
|
|
2774
|
+
"missing"
|
|
2775
|
+
])).toEqual([]);
|
|
2776
|
+
expect2(await tokens([])).toEqual([]);
|
|
2777
|
+
expect2((await store.listSignalWaitersByRun?.([
|
|
2778
|
+
"a"
|
|
2779
|
+
]))?.find((w) => w.seq === 0)).toEqual({
|
|
2780
|
+
token: "child:a.all.0.0",
|
|
2781
|
+
runId: "a",
|
|
2782
|
+
seq: 0,
|
|
2783
|
+
parallelGroup: "all:0"
|
|
2784
|
+
});
|
|
2785
|
+
});
|
|
2786
|
+
t("hasSignalWaiter answers by run and exact token prefix", async () => {
|
|
2787
|
+
expect2(store.hasSignalWaiter).toBeTypeOf("function");
|
|
2788
|
+
const has = /* @__PURE__ */ __name((runId, prefix) => store.hasSignalWaiter?.(runId, prefix), "has");
|
|
2789
|
+
await store.putSignalWaiter({
|
|
2790
|
+
token: "child:p_1.all.3.0",
|
|
2791
|
+
runId: "p_1",
|
|
2792
|
+
seq: 3,
|
|
2793
|
+
parallelGroup: "all:3"
|
|
2794
|
+
});
|
|
2795
|
+
await store.putSignalWaiter({
|
|
2796
|
+
token: "child:p_1.all.4.0.all.0.0",
|
|
2797
|
+
runId: "p_1.all.4.0",
|
|
2798
|
+
seq: 0
|
|
2799
|
+
});
|
|
2800
|
+
await store.putSignalWaiter({
|
|
2801
|
+
token: "child:pX1.all.3.0",
|
|
2802
|
+
runId: "p_1",
|
|
2803
|
+
seq: 9
|
|
2804
|
+
});
|
|
2805
|
+
expect2(await has("p_1", "child:p_1.all.3.")).toBe(true);
|
|
2806
|
+
expect2(await has("p_1", "child:p_1.all.4.")).toBe(false);
|
|
2807
|
+
expect2(await has("other", "child:p_1.all.3.")).toBe(false);
|
|
2808
|
+
expect2(await has("p_1", "child:p%.all.3.")).toBe(false);
|
|
2809
|
+
expect2(await has("p_1", "CHILD:p_1.all.3.")).toBe(false);
|
|
2810
|
+
await store.takeSignalWaiter("child:p_1.all.3.0");
|
|
2811
|
+
expect2(await has("p_1", "child:p_1.all.3.")).toBe(false);
|
|
2812
|
+
});
|
|
1754
2813
|
t("removeSignalWaiter deletes only the exact (token, runId, seq) row, never a different run's", async () => {
|
|
1755
2814
|
await store.putSignalWaiter({
|
|
1756
2815
|
token: "approve-1",
|