@dudousxd/nestjs-durable-core 0.80.1 → 0.82.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.cjs +683 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +357 -21
- package/dist/index.d.ts +357 -21
- package/dist/index.js +672 -123
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -928,6 +928,9 @@ async function assertConcurrency(store, workflow, quota) {
|
|
|
928
928
|
__name(assertConcurrency, "assertConcurrency");
|
|
929
929
|
|
|
930
930
|
// src/entities.ts
|
|
931
|
+
var NUDGE = {
|
|
932
|
+
nudge: true
|
|
933
|
+
};
|
|
931
934
|
var ENTITY_WORKFLOW = "__entity";
|
|
932
935
|
var entityRunId = /* @__PURE__ */ __name((name, key) => `entity:${name}:${key}`, "entityRunId");
|
|
933
936
|
var Entities = class {
|
|
@@ -935,9 +938,11 @@ var Entities = class {
|
|
|
935
938
|
__name(this, "Entities");
|
|
936
939
|
}
|
|
937
940
|
host;
|
|
941
|
+
workflowHost;
|
|
938
942
|
configs = /* @__PURE__ */ new Map();
|
|
939
|
-
constructor(host) {
|
|
943
|
+
constructor(host, workflowHost) {
|
|
940
944
|
this.host = host;
|
|
945
|
+
this.workflowHost = workflowHost;
|
|
941
946
|
host.register(ENTITY_WORKFLOW, "1", (ctx, input) => this.run(ctx, input));
|
|
942
947
|
}
|
|
943
948
|
register(name, config) {
|
|
@@ -959,6 +964,52 @@ var Entities = class {
|
|
|
959
964
|
}
|
|
960
965
|
});
|
|
961
966
|
}
|
|
967
|
+
/**
|
|
968
|
+
* The durable half of a send from INSIDE a workflow (`ctx.callEntity` / `ctx.signalEntity`): write
|
|
969
|
+
* the op to the entity's signal buffer and return once it is stored, so the caller can checkpoint
|
|
970
|
+
* after it. The op carries `id`, the caller's stable `<runId>:<seq>`, so if the caller dies between
|
|
971
|
+
* this write and its checkpoint and sends again on replay, the entity skips the second copy. The
|
|
972
|
+
* entity's run is also ensured here, awaited (`start` only enqueues, it never runs the body
|
|
973
|
+
* inline), so an op sent on first contact can't be stranded in a buffer no run will ever read if
|
|
974
|
+
* the process dies right after. The wake-up ({@link kick}) is separate and may be lost: a busy
|
|
975
|
+
* entity reads the buffer at its next wait, and a parked one is paired with it by the engine's
|
|
976
|
+
* `reconcileMs` sweep. Returns `false` on a host without a workflow host.
|
|
977
|
+
*/
|
|
978
|
+
async enqueue(name, key, op, arg, id, reply) {
|
|
979
|
+
if (!this.workflowHost) return false;
|
|
980
|
+
const message = {
|
|
981
|
+
op,
|
|
982
|
+
arg,
|
|
983
|
+
id,
|
|
984
|
+
...reply ? {
|
|
985
|
+
reply
|
|
986
|
+
} : {}
|
|
987
|
+
};
|
|
988
|
+
const runId = entityRunId(name, key);
|
|
989
|
+
await this.workflowHost.bufferSignal(runId, message);
|
|
990
|
+
await this.workflowHost.ensureStarted(ENTITY_WORKFLOW, {
|
|
991
|
+
name,
|
|
992
|
+
key
|
|
993
|
+
}, runId);
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Make the entity act on what {@link enqueue} buffered: wake its run if it is parked on its wait
|
|
998
|
+
* (the run itself was ensured by `enqueue`; ensured again here, idempotently, for a caller replay
|
|
999
|
+
* that only re-kicks). A run that is busy needs nothing: its next wait reads the buffer before
|
|
1000
|
+
* suspending. The wake carries a no-op `nudge`, never the op itself, so the op stays in
|
|
1001
|
+
* the buffer in arrival order and a lost or repeated kick costs at most one empty iteration.
|
|
1002
|
+
*/
|
|
1003
|
+
async kick(name, key) {
|
|
1004
|
+
const host = this.workflowHost;
|
|
1005
|
+
if (!host) return;
|
|
1006
|
+
const runId = entityRunId(name, key);
|
|
1007
|
+
await host.ensureStarted(ENTITY_WORKFLOW, {
|
|
1008
|
+
name,
|
|
1009
|
+
key
|
|
1010
|
+
}, runId);
|
|
1011
|
+
await host.wakeWaiter(runId, NUDGE);
|
|
1012
|
+
}
|
|
962
1013
|
async signal(name, key, op, arg) {
|
|
963
1014
|
await this.dispatch(name, key, op, arg);
|
|
964
1015
|
}
|
|
@@ -972,11 +1023,18 @@ var Entities = class {
|
|
|
972
1023
|
if (!config) throw new FatalError(`entity "${name}" is not registered`);
|
|
973
1024
|
const token = entityRunId(name, key);
|
|
974
1025
|
let state = config.initialState();
|
|
1026
|
+
const seen = /* @__PURE__ */ new Set();
|
|
975
1027
|
for (let i = 0; ; i += 1) {
|
|
976
1028
|
const msg = await ctx.waitForSignal(token);
|
|
1029
|
+
if (msg.nudge) continue;
|
|
1030
|
+
if (msg.id !== void 0) {
|
|
1031
|
+
if (seen.has(msg.id)) continue;
|
|
1032
|
+
seen.add(msg.id);
|
|
1033
|
+
}
|
|
1034
|
+
const op = msg.op;
|
|
977
1035
|
const out = await ctx.localStep(`op:${i}`, async () => {
|
|
978
|
-
const handler = config.handlers[
|
|
979
|
-
if (!handler) throw new FatalError(`entity "${name}" has no handler for op "${
|
|
1036
|
+
const handler = config.handlers[op];
|
|
1037
|
+
if (!handler) throw new FatalError(`entity "${name}" has no handler for op "${op}"`);
|
|
980
1038
|
const result = await handler(state, msg.arg);
|
|
981
1039
|
return {
|
|
982
1040
|
result,
|
|
@@ -986,8 +1044,9 @@ var Entities = class {
|
|
|
986
1044
|
state = out.state;
|
|
987
1045
|
await ctx.setEvent("state", state);
|
|
988
1046
|
if (msg.reply) {
|
|
1047
|
+
const reply = msg.reply;
|
|
989
1048
|
await ctx.localStep(`reply:${i}`, async () => {
|
|
990
|
-
await this.host.signal(
|
|
1049
|
+
await this.host.signal(reply, out.result);
|
|
991
1050
|
});
|
|
992
1051
|
}
|
|
993
1052
|
}
|
|
@@ -1911,6 +1970,10 @@ function checkpointNameKind(name) {
|
|
|
1911
1970
|
return "step";
|
|
1912
1971
|
}
|
|
1913
1972
|
__name(checkpointNameKind, "checkpointNameKind");
|
|
1973
|
+
function poolNamespace(namespace) {
|
|
1974
|
+
return namespace === "" ? "default" : namespace;
|
|
1975
|
+
}
|
|
1976
|
+
__name(poolNamespace, "poolNamespace");
|
|
1914
1977
|
var TERMINAL_RUN_STATUSES = [
|
|
1915
1978
|
"completed",
|
|
1916
1979
|
"failed",
|
|
@@ -2042,6 +2105,19 @@ var WEBHOOK_PREFIX = "wh:";
|
|
|
2042
2105
|
var CHILD_PREFIX = "child:";
|
|
2043
2106
|
var BREAKPOINT_PREFIX = "bp:";
|
|
2044
2107
|
var EVENT_PREFIX = EVENT_TOKEN_PREFIX;
|
|
2108
|
+
function parseWebhookToken(token) {
|
|
2109
|
+
if (!token.startsWith(WEBHOOK_PREFIX)) return null;
|
|
2110
|
+
const rest = token.slice(WEBHOOK_PREFIX.length);
|
|
2111
|
+
const at = rest.lastIndexOf(":");
|
|
2112
|
+
if (at <= 0) return null;
|
|
2113
|
+
const seqText = rest.slice(at + 1);
|
|
2114
|
+
if (!/^\d+$/.test(seqText)) return null;
|
|
2115
|
+
return {
|
|
2116
|
+
runId: rest.slice(0, at),
|
|
2117
|
+
seq: Number(seqText)
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
__name(parseWebhookToken, "parseWebhookToken");
|
|
2045
2121
|
function classifyWaiterToken(token) {
|
|
2046
2122
|
if (token.startsWith(WEBHOOK_PREFIX)) return {
|
|
2047
2123
|
on: "webhook",
|
|
@@ -2579,6 +2655,11 @@ __name(errorMessage, "errorMessage");
|
|
|
2579
2655
|
|
|
2580
2656
|
// src/singleton-admission.ts
|
|
2581
2657
|
var SINGLETON_ADMITTED_TAG = "durable:singleton:admitted";
|
|
2658
|
+
var SINGLETON_TRACKED_TAG = "durable:singleton:tracked";
|
|
2659
|
+
var SINGLETON_RESERVED_TAGS = [
|
|
2660
|
+
SINGLETON_ADMITTED_TAG,
|
|
2661
|
+
SINGLETON_TRACKED_TAG
|
|
2662
|
+
];
|
|
2582
2663
|
var SINGLETON_ACTIVE_STATUSES = [
|
|
2583
2664
|
"pending",
|
|
2584
2665
|
"running",
|
|
@@ -2586,6 +2667,33 @@ var SINGLETON_ACTIVE_STATUSES = [
|
|
|
2586
2667
|
"blocked",
|
|
2587
2668
|
"cancelling"
|
|
2588
2669
|
];
|
|
2670
|
+
var LEGACY_SCAN_STATUSES = [
|
|
2671
|
+
"running",
|
|
2672
|
+
"suspended",
|
|
2673
|
+
"cancelling"
|
|
2674
|
+
];
|
|
2675
|
+
var byAdmissionOrder = /* @__PURE__ */ __name((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id), "byAdmissionOrder");
|
|
2676
|
+
function withoutSingletonMarkers(tags) {
|
|
2677
|
+
return tags?.filter((tag) => !SINGLETON_RESERVED_TAGS.includes(tag));
|
|
2678
|
+
}
|
|
2679
|
+
__name(withoutSingletonMarkers, "withoutSingletonMarkers");
|
|
2680
|
+
function withSingletonTracked(tags) {
|
|
2681
|
+
const current = tags ?? [];
|
|
2682
|
+
if (current.includes(SINGLETON_ADMITTED_TAG) || current.includes(SINGLETON_TRACKED_TAG)) {
|
|
2683
|
+
return current;
|
|
2684
|
+
}
|
|
2685
|
+
return [
|
|
2686
|
+
...current,
|
|
2687
|
+
SINGLETON_TRACKED_TAG
|
|
2688
|
+
];
|
|
2689
|
+
}
|
|
2690
|
+
__name(withSingletonTracked, "withSingletonTracked");
|
|
2691
|
+
function legacySingletonHolders(active, runId, limit) {
|
|
2692
|
+
const scanned = active.filter((run) => LEGACY_SCAN_STATUSES.includes(run.status)).sort(byAdmissionOrder);
|
|
2693
|
+
const window = new Set(scanned.slice(0, limit).map((run) => run.id));
|
|
2694
|
+
return scanned.filter((run) => run.id !== runId && !run.tags?.includes(SINGLETON_ADMITTED_TAG) && (!run.tags?.includes(SINGLETON_TRACKED_TAG) || window.has(run.id)));
|
|
2695
|
+
}
|
|
2696
|
+
__name(legacySingletonHolders, "legacySingletonHolders");
|
|
2589
2697
|
function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
|
|
2590
2698
|
if (!Number.isInteger(limit) || limit < 1) throw new Error("singleton limit must be a positive integer");
|
|
2591
2699
|
const active = runs.filter((run) => run.workflow === workflow && SINGLETON_ACTIVE_STATUSES.includes(run.status) && run.tags?.includes(tag));
|
|
@@ -2594,12 +2702,13 @@ function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
|
|
|
2594
2702
|
if (candidate.tags?.includes(SINGLETON_ADMITTED_TAG)) return candidate.tags;
|
|
2595
2703
|
if (candidate.status === "cancelling") return null;
|
|
2596
2704
|
const holders = active.filter((run) => run.tags?.includes(SINGLETON_ADMITTED_TAG));
|
|
2597
|
-
const
|
|
2705
|
+
const legacy = new Set(legacySingletonHolders(active, runId, limit).map((run) => run.id));
|
|
2706
|
+
const remaining = limit - holders.length - legacy.size;
|
|
2598
2707
|
if (remaining <= 0) return null;
|
|
2599
|
-
const waiters = active.filter((run) => !run.tags?.includes(SINGLETON_ADMITTED_TAG)
|
|
2708
|
+
const waiters = active.filter((run) => !run.tags?.includes(SINGLETON_ADMITTED_TAG) && !legacy.has(run.id)).sort(byAdmissionOrder);
|
|
2600
2709
|
if (!waiters.slice(0, remaining).some((run) => run.id === runId)) return null;
|
|
2601
2710
|
return [
|
|
2602
|
-
...candidate.tags ?? [],
|
|
2711
|
+
...(candidate.tags ?? []).filter((t) => t !== SINGLETON_TRACKED_TAG),
|
|
2603
2712
|
SINGLETON_ADMITTED_TAG
|
|
2604
2713
|
];
|
|
2605
2714
|
}
|
|
@@ -2609,7 +2718,7 @@ async function clearTerminalSingletonAdmission(runId, patch, getRun) {
|
|
|
2609
2718
|
const tags = "tags" in patch ? patch.tags : (await getRun(runId))?.tags;
|
|
2610
2719
|
return {
|
|
2611
2720
|
...patch,
|
|
2612
|
-
tags: tags
|
|
2721
|
+
tags: withoutSingletonMarkers(tags)
|
|
2613
2722
|
};
|
|
2614
2723
|
}
|
|
2615
2724
|
__name(clearTerminalSingletonAdmission, "clearTerminalSingletonAdmission");
|
|
@@ -2620,6 +2729,7 @@ function singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt)
|
|
|
2620
2729
|
return {
|
|
2621
2730
|
status: "suspended",
|
|
2622
2731
|
wakeAt: retryWakeAt,
|
|
2732
|
+
tags: withSingletonTracked(candidate.tags),
|
|
2623
2733
|
updatedAt: /* @__PURE__ */ new Date()
|
|
2624
2734
|
};
|
|
2625
2735
|
}
|
|
@@ -2672,7 +2782,7 @@ var SingletonGate = class {
|
|
|
2672
2782
|
const admitted = await this.deps.store.tryAdmitSingleton(run.id, this.tag(cfg, run.input), run.workflow, cfg.limit ?? 1, this.retryWakeAt());
|
|
2673
2783
|
if (admitted && !run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
|
|
2674
2784
|
run.tags = [
|
|
2675
|
-
...run.tags ?? [],
|
|
2785
|
+
...(run.tags ?? []).filter((tag) => tag !== SINGLETON_TRACKED_TAG),
|
|
2676
2786
|
SINGLETON_ADMITTED_TAG
|
|
2677
2787
|
];
|
|
2678
2788
|
}
|
|
@@ -2706,6 +2816,66 @@ var SingletonGate = class {
|
|
|
2706
2816
|
}
|
|
2707
2817
|
};
|
|
2708
2818
|
|
|
2819
|
+
// src/stalled-runs.ts
|
|
2820
|
+
var DEFAULT_STALLED_AFTER_MS = 15 * 6e4;
|
|
2821
|
+
async function findStalledRuns(store, options = {}) {
|
|
2822
|
+
const now = options.now ?? Date.now();
|
|
2823
|
+
const thresholdMs = options.thresholdMs ?? DEFAULT_STALLED_AFTER_MS;
|
|
2824
|
+
const scan = Math.max(1, options.scan ?? 1e3);
|
|
2825
|
+
const pageSize = Math.min(200, scan);
|
|
2826
|
+
const stalled = [];
|
|
2827
|
+
for (let offset = 0; offset < scan; offset += pageSize) {
|
|
2828
|
+
const limit = Math.min(pageSize, scan - offset);
|
|
2829
|
+
const page = await store.listRuns({
|
|
2830
|
+
statuses: [
|
|
2831
|
+
"running",
|
|
2832
|
+
"suspended"
|
|
2833
|
+
],
|
|
2834
|
+
...options.namespace !== void 0 ? {
|
|
2835
|
+
namespace: options.namespace
|
|
2836
|
+
} : {},
|
|
2837
|
+
limit,
|
|
2838
|
+
offset
|
|
2839
|
+
});
|
|
2840
|
+
for (const run of page) {
|
|
2841
|
+
const ageMs = now - run.updatedAt.getTime();
|
|
2842
|
+
if (ageMs < thresholdMs) continue;
|
|
2843
|
+
const info2 = await stalledInfo(store, run, now, thresholdMs, ageMs);
|
|
2844
|
+
if (info2) stalled.push(info2);
|
|
2845
|
+
}
|
|
2846
|
+
if (page.length < limit) break;
|
|
2847
|
+
}
|
|
2848
|
+
return stalled;
|
|
2849
|
+
}
|
|
2850
|
+
__name(findStalledRuns, "findStalledRuns");
|
|
2851
|
+
async function stalledInfo(store, run, now, thresholdMs, ageMs) {
|
|
2852
|
+
let stalePending = null;
|
|
2853
|
+
for (const cp of await store.listCheckpoints(run.id)) {
|
|
2854
|
+
if (cp.kind !== "remote" || cp.status !== "pending") continue;
|
|
2855
|
+
const stepAgeMs = now - cp.enqueuedAt.getTime();
|
|
2856
|
+
if (stepAgeMs < thresholdMs) continue;
|
|
2857
|
+
if (cp.wakeAt != null && cp.wakeAt > now) continue;
|
|
2858
|
+
if (!stalePending || stepAgeMs > stalePending.ageMs) {
|
|
2859
|
+
stalePending = {
|
|
2860
|
+
seq: cp.seq,
|
|
2861
|
+
name: cp.name,
|
|
2862
|
+
attempts: cp.attempts,
|
|
2863
|
+
ageMs: stepAgeMs,
|
|
2864
|
+
leaseUntil: cp.wakeAt ?? null
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
const wakeForever = run.status === "suspended" && run.wakeAt == null;
|
|
2869
|
+
if (!wakeForever && !stalePending) return null;
|
|
2870
|
+
return {
|
|
2871
|
+
run,
|
|
2872
|
+
ageMs,
|
|
2873
|
+
wakeForever,
|
|
2874
|
+
stalePending
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
__name(stalledInfo, "stalledInfo");
|
|
2878
|
+
|
|
2709
2879
|
// src/transport-pool.ts
|
|
2710
2880
|
var TransportPool = class {
|
|
2711
2881
|
static {
|
|
@@ -3567,30 +3737,82 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
3567
3737
|
}));
|
|
3568
3738
|
return id;
|
|
3569
3739
|
}, "startChild");
|
|
3740
|
+
const sendEntityOp = /* @__PURE__ */ __name(async (name, key, op, arg, seq, reply) => await host.enqueueEntityOp?.(name, key, op, arg, `${runId}:${seq}`, reply) ?? false, "sendEntityOp");
|
|
3570
3741
|
const callEntity = /* @__PURE__ */ __name(async (name, key, op, arg) => {
|
|
3571
3742
|
const current = pos.next();
|
|
3572
|
-
|
|
3743
|
+
let existing = await readCheckpoint(current);
|
|
3573
3744
|
if (existing && existing.status === "completed") return existing.output;
|
|
3574
3745
|
const reply = `entityreply:${runId}:${current}`;
|
|
3746
|
+
const intentName = `signal:${reply}`;
|
|
3747
|
+
if (existing && existing.name !== intentName) {
|
|
3748
|
+
throw new NonDeterminismError(runId, current, intentName, existing.name);
|
|
3749
|
+
}
|
|
3750
|
+
if (existing && replay?.has(current)) {
|
|
3751
|
+
existing = await store.getCheckpoint(runId, current) ?? existing;
|
|
3752
|
+
if (existing.status === "completed") return existing.output;
|
|
3753
|
+
}
|
|
3754
|
+
const markSent = /* @__PURE__ */ __name(() => store.saveCheckpointIfPending?.(instantCheckpoint({
|
|
3755
|
+
runId,
|
|
3756
|
+
seq: current,
|
|
3757
|
+
name: intentName,
|
|
3758
|
+
kind: "signal",
|
|
3759
|
+
status: "pending",
|
|
3760
|
+
output: {
|
|
3761
|
+
sent: true
|
|
3762
|
+
}
|
|
3763
|
+
})) ?? Promise.resolve(false), "markSent");
|
|
3764
|
+
if (existing) {
|
|
3765
|
+
const buffered = await consumeBuffered(reply, current);
|
|
3766
|
+
if (buffered) {
|
|
3767
|
+
await store.removeSignalWaiter({
|
|
3768
|
+
token: reply,
|
|
3769
|
+
runId,
|
|
3770
|
+
seq: current
|
|
3771
|
+
});
|
|
3772
|
+
return buffered.value;
|
|
3773
|
+
}
|
|
3774
|
+
if (existing.output?.sent !== true) {
|
|
3775
|
+
if (await sendEntityOp(name, key, op, arg, current, reply)) await markSent();
|
|
3776
|
+
}
|
|
3777
|
+
host.kickEntity?.(name, key);
|
|
3778
|
+
throw new WorkflowSuspended();
|
|
3779
|
+
}
|
|
3575
3780
|
await store.putSignalWaiter({
|
|
3576
3781
|
token: reply,
|
|
3577
3782
|
runId,
|
|
3578
3783
|
seq: current
|
|
3579
3784
|
});
|
|
3580
|
-
host.
|
|
3785
|
+
if (!host.enqueueEntityOp) {
|
|
3786
|
+
host.signalEntity?.(name, key, op, arg, reply);
|
|
3787
|
+
throw new WorkflowSuspended();
|
|
3788
|
+
}
|
|
3789
|
+
await writeCheckpoint(instantCheckpoint({
|
|
3790
|
+
runId,
|
|
3791
|
+
seq: current,
|
|
3792
|
+
name: intentName,
|
|
3793
|
+
kind: "signal",
|
|
3794
|
+
status: "pending",
|
|
3795
|
+
output: {
|
|
3796
|
+
sent: false
|
|
3797
|
+
}
|
|
3798
|
+
}));
|
|
3799
|
+
if (await sendEntityOp(name, key, op, arg, current, reply)) await markSent();
|
|
3800
|
+
host.kickEntity?.(name, key);
|
|
3581
3801
|
throw new WorkflowSuspended();
|
|
3582
3802
|
}, "callEntity");
|
|
3583
3803
|
const signalEntity = /* @__PURE__ */ __name(async (name, key, op, arg) => {
|
|
3584
3804
|
const current = pos.next();
|
|
3585
3805
|
const existing = await readCheckpoint(current);
|
|
3586
3806
|
if (existing && existing.status === "completed") return;
|
|
3587
|
-
|
|
3807
|
+
const durable = await sendEntityOp(name, key, op, arg, current);
|
|
3808
|
+
if (!durable) host.signalEntity?.(name, key, op, arg);
|
|
3588
3809
|
await writeCheckpoint(instantCheckpoint({
|
|
3589
3810
|
runId,
|
|
3590
3811
|
seq: current,
|
|
3591
3812
|
name: `entitysig:${name}:${key}`,
|
|
3592
3813
|
kind: "local"
|
|
3593
3814
|
}));
|
|
3815
|
+
if (durable) host.kickEntity?.(name, key);
|
|
3594
3816
|
}, "signalEntity");
|
|
3595
3817
|
const breakpoint = /* @__PURE__ */ __name(async (label) => {
|
|
3596
3818
|
const current = pos.next();
|
|
@@ -3658,17 +3880,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
3658
3880
|
const deadlineSeq = timeoutMs == null ? -1 : pos.next();
|
|
3659
3881
|
const existing = await readCheckpoint(current);
|
|
3660
3882
|
if (existing && existing.status === "completed") return existing.output;
|
|
3661
|
-
|
|
3662
|
-
|
|
3883
|
+
const buffered = await consumeBuffered(token, current);
|
|
3884
|
+
if (buffered) return buffered.value;
|
|
3885
|
+
const deadline = timeoutMs == null ? void 0 : await stampDeadline(deadlineSeq, token, timeoutMs);
|
|
3886
|
+
if (deadline !== void 0 && host.clock() >= deadline) {
|
|
3887
|
+
await store.removeSignalWaiter({
|
|
3663
3888
|
token,
|
|
3664
3889
|
runId,
|
|
3665
3890
|
seq: current
|
|
3666
|
-
});
|
|
3667
|
-
throw new WorkflowSuspended();
|
|
3668
|
-
}
|
|
3669
|
-
const deadline = await stampDeadline(deadlineSeq, token, timeoutMs);
|
|
3670
|
-
if (host.clock() >= deadline) {
|
|
3671
|
-
await store.takeSignalWaiter(token).catch(() => void 0);
|
|
3891
|
+
}).catch(() => void 0);
|
|
3672
3892
|
throw new SignalTimeoutError(token, timeoutMs);
|
|
3673
3893
|
}
|
|
3674
3894
|
await store.putSignalWaiter({
|
|
@@ -3676,6 +3896,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
3676
3896
|
runId,
|
|
3677
3897
|
seq: current
|
|
3678
3898
|
});
|
|
3899
|
+
const lateBuffered = await consumeBuffered(token, current);
|
|
3900
|
+
if (lateBuffered) {
|
|
3901
|
+
await store.removeSignalWaiter({
|
|
3902
|
+
token,
|
|
3903
|
+
runId,
|
|
3904
|
+
seq: current
|
|
3905
|
+
});
|
|
3906
|
+
return lateBuffered.value;
|
|
3907
|
+
}
|
|
3679
3908
|
throw new WorkflowSuspended(deadline);
|
|
3680
3909
|
}, "wait");
|
|
3681
3910
|
return {
|
|
@@ -3867,6 +4096,12 @@ var SETTLE_ELIGIBLE_STATUSES = [
|
|
|
3867
4096
|
"failed",
|
|
3868
4097
|
"cancelling"
|
|
3869
4098
|
];
|
|
4099
|
+
var TIMEOUT_SWEPT_STATUSES = [
|
|
4100
|
+
"pending",
|
|
4101
|
+
"running",
|
|
4102
|
+
"suspended",
|
|
4103
|
+
"blocked"
|
|
4104
|
+
];
|
|
3870
4105
|
var SUSPEND_ELIGIBLE_STATUSES = SETTLE_ELIGIBLE_STATUSES.filter((status) => status !== "cancelling");
|
|
3871
4106
|
var RECOVERY_BATCH = 500;
|
|
3872
4107
|
var NamespaceMismatch = class NamespaceMismatch2 extends Error {
|
|
@@ -3889,6 +4124,12 @@ var WorkflowEngine = class {
|
|
|
3889
4124
|
instanceId;
|
|
3890
4125
|
namespace;
|
|
3891
4126
|
leaseMs;
|
|
4127
|
+
/**
|
|
4128
|
+
* Leases held by this `instanceId` that expire strictly before this epoch ms were taken by a
|
|
4129
|
+
* PREVIOUS process with the same id (every lease this one takes or renews expires at
|
|
4130
|
+
* `clock() + leaseMs`, so at or after it). See {@link orphanedRuns}.
|
|
4131
|
+
*/
|
|
4132
|
+
ownLeaseCutoffMs;
|
|
3892
4133
|
maxRecoveryAttempts;
|
|
3893
4134
|
remoteAdvanceSilenceMs;
|
|
3894
4135
|
reconcileMs;
|
|
@@ -3992,6 +4233,18 @@ var WorkflowEngine = class {
|
|
|
3992
4233
|
stepQueue = /* @__PURE__ */ new Map();
|
|
3993
4234
|
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
3994
4235
|
inflight = /* @__PURE__ */ new Set();
|
|
4236
|
+
/**
|
|
4237
|
+
* The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
|
|
4238
|
+
* and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
|
|
4239
|
+
* releases when its budget runs out.
|
|
4240
|
+
*/
|
|
4241
|
+
executionLeases = /* @__PURE__ */ new Map();
|
|
4242
|
+
/** Pager hooks for stranded runs (see {@link onStalled}). */
|
|
4243
|
+
stalledListeners = /* @__PURE__ */ new Set();
|
|
4244
|
+
/** Runs already paged about, by id → the `updatedAt` of the paged episode (see {@link sweepStalled}). */
|
|
4245
|
+
notifiedStalled = /* @__PURE__ */ new Map();
|
|
4246
|
+
lastStalledSweepAt = Number.NEGATIVE_INFINITY;
|
|
4247
|
+
stalledAfterMs;
|
|
3995
4248
|
draining = false;
|
|
3996
4249
|
/** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
|
|
3997
4250
|
drainSpentMs = 0;
|
|
@@ -4010,11 +4263,14 @@ var WorkflowEngine = class {
|
|
|
4010
4263
|
this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
|
|
4011
4264
|
this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
|
|
4012
4265
|
this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
|
|
4013
|
-
this.namespace = deps.namespace;
|
|
4266
|
+
this.namespace = poolNamespace(deps.namespace);
|
|
4014
4267
|
this.leaseMs = deps.leaseMs ?? 3e4;
|
|
4268
|
+
this.ownLeaseCutoffMs = this.clock() + this.leaseMs;
|
|
4015
4269
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
4016
4270
|
this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
|
|
4017
4271
|
this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
|
|
4272
|
+
const stalledAfter = deps.stalledAfterMs;
|
|
4273
|
+
this.stalledAfterMs = stalledAfter !== void 0 && Number.isFinite(stalledAfter) ? Math.max(6e4, stalledAfter) : DEFAULT_STALLED_AFTER_MS;
|
|
4018
4274
|
const batch = deps.timerBatchSize;
|
|
4019
4275
|
this.timerBatchSize = batch !== void 0 && Number.isFinite(batch) && batch >= 1 ? Math.floor(batch) : 500;
|
|
4020
4276
|
this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
|
|
@@ -4131,7 +4387,11 @@ var WorkflowEngine = class {
|
|
|
4131
4387
|
}
|
|
4132
4388
|
});
|
|
4133
4389
|
this.accumulators = new EventAccumulators(this);
|
|
4134
|
-
this.entities = new Entities(this
|
|
4390
|
+
this.entities = new Entities(this, {
|
|
4391
|
+
bufferSignal: /* @__PURE__ */ __name((token, payload) => this.store.bufferSignal(token, payload), "bufferSignal"),
|
|
4392
|
+
wakeWaiter: /* @__PURE__ */ __name((token, payload) => this.wakeSignalWaiter(token, payload), "wakeWaiter"),
|
|
4393
|
+
ensureStarted: /* @__PURE__ */ __name((workflow, input, runId) => this.start(workflow, input, runId), "ensureStarted")
|
|
4394
|
+
});
|
|
4135
4395
|
}
|
|
4136
4396
|
/**
|
|
4137
4397
|
* Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
|
|
@@ -4354,6 +4614,64 @@ var WorkflowEngine = class {
|
|
|
4354
4614
|
this.deadListeners.add(listener);
|
|
4355
4615
|
return () => this.deadListeners.delete(listener);
|
|
4356
4616
|
}
|
|
4617
|
+
/**
|
|
4618
|
+
* Register a pager hook for STRANDED runs: an in-flight run untouched past `stalledAfterMs` that is
|
|
4619
|
+
* either suspended with NO wake timer (wake-forever: nothing will ever re-drive it) or parked on an
|
|
4620
|
+
* old pending remote step with no live lease (dispatched and, most likely, lost). The timer poller
|
|
4621
|
+
* sweeps for these ({@link sweepStalled}, throttled to one pass a minute) and notifies each episode
|
|
4622
|
+
* ONCE per instance: a run is paged again only after it makes progress and strands again. Wire your
|
|
4623
|
+
* alerting here like {@link onDead}. A listener that throws never breaks the sweep. Returns an
|
|
4624
|
+
* unsubscribe function.
|
|
4625
|
+
*/
|
|
4626
|
+
onStalled(listener) {
|
|
4627
|
+
this.stalledListeners.add(listener);
|
|
4628
|
+
return () => this.stalledListeners.delete(listener);
|
|
4629
|
+
}
|
|
4630
|
+
/**
|
|
4631
|
+
* Detect stranded in-flight runs and notify the {@link onStalled} listeners. Self-throttled to one
|
|
4632
|
+
* pass a minute, skipped while draining, and a no-op without listeners — call it every tick (the
|
|
4633
|
+
* NestJS timer poller does). Scoped to this engine's `namespace` (an operator engine with no
|
|
4634
|
+
* namespace sweeps every namespace). Returns the runs notified THIS pass.
|
|
4635
|
+
*/
|
|
4636
|
+
async sweepStalled(now = this.clock()) {
|
|
4637
|
+
if (this.stalledListeners.size === 0 || this.draining) return [];
|
|
4638
|
+
if (now - this.lastStalledSweepAt < 6e4) return [];
|
|
4639
|
+
this.lastStalledSweepAt = now;
|
|
4640
|
+
const notified = [];
|
|
4641
|
+
for (const info2 of await this.listStalledRuns({
|
|
4642
|
+
now
|
|
4643
|
+
})) {
|
|
4644
|
+
const episode = info2.run.updatedAt.getTime();
|
|
4645
|
+
if (this.notifiedStalled.get(info2.run.id) === episode) continue;
|
|
4646
|
+
this.notifiedStalled.set(info2.run.id, episode);
|
|
4647
|
+
if (this.notifiedStalled.size > 1e3) this.notifiedStalled.clear();
|
|
4648
|
+
for (const listener of this.stalledListeners) {
|
|
4649
|
+
try {
|
|
4650
|
+
listener(info2);
|
|
4651
|
+
} catch {
|
|
4652
|
+
}
|
|
4653
|
+
}
|
|
4654
|
+
notified.push(info2);
|
|
4655
|
+
}
|
|
4656
|
+
return notified;
|
|
4657
|
+
}
|
|
4658
|
+
/**
|
|
4659
|
+
* The stranded runs right now, without notifying anyone: the query behind {@link sweepStalled} and
|
|
4660
|
+
* the CLI's `durable inspect --stale`. A `running`/`suspended` run qualifies when it has been
|
|
4661
|
+
* untouched for `thresholdMs` (default `stalledAfterMs`) AND either it is suspended with no wake
|
|
4662
|
+
* timer, or its oldest pending remote step is older than the threshold with no lease in the future
|
|
4663
|
+
* (a step with a `timeoutMs`, a heartbeat or `remoteRedispatchMs` keeps a lease; a long
|
|
4664
|
+
* `ctx.sleep` has a future `wakeAt`; neither is stranded). Scans up to `scan` candidates (default
|
|
4665
|
+
* 1000), newest first, in this engine's namespace.
|
|
4666
|
+
*/
|
|
4667
|
+
async listStalledRuns(options = {}) {
|
|
4668
|
+
return findStalledRuns(this.store, {
|
|
4669
|
+
now: options.now ?? this.clock(),
|
|
4670
|
+
thresholdMs: options.thresholdMs ?? this.stalledAfterMs,
|
|
4671
|
+
scan: options.scan,
|
|
4672
|
+
namespace: this.namespace
|
|
4673
|
+
});
|
|
4674
|
+
}
|
|
4357
4675
|
notifyDead(run) {
|
|
4358
4676
|
for (const fn of this.deadListeners) {
|
|
4359
4677
|
try {
|
|
@@ -4407,7 +4725,7 @@ var WorkflowEngine = class {
|
|
|
4407
4725
|
// tenant-suffixed group the run will dispatch to — an operator starting a tenant's run via the
|
|
4408
4726
|
// `onStartRun` wire (opts.namespace = tenant) must find the live `<workflow>@<tenant>` group,
|
|
4409
4727
|
// not the bare group. Without this the start-run entry path can't reach a suffixed-only worker.
|
|
4410
|
-
namespace: opts?.namespace ?? this.namespace,
|
|
4728
|
+
namespace: poolNamespace(opts?.namespace) ?? this.namespace,
|
|
4411
4729
|
input,
|
|
4412
4730
|
createdAt: /* @__PURE__ */ new Date(),
|
|
4413
4731
|
updatedAt: /* @__PURE__ */ new Date()
|
|
@@ -4444,7 +4762,7 @@ var WorkflowEngine = class {
|
|
|
4444
4762
|
VERSION_UNDECLARED_TAG
|
|
4445
4763
|
] : []
|
|
4446
4764
|
];
|
|
4447
|
-
const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => tag
|
|
4765
|
+
const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => !SINGLETON_RESERVED_TAGS.includes(tag));
|
|
4448
4766
|
if (registered.singleton) {
|
|
4449
4767
|
await this.singletons.assertCapacity(name, registered.singleton, input);
|
|
4450
4768
|
}
|
|
@@ -4458,7 +4776,7 @@ var WorkflowEngine = class {
|
|
|
4458
4776
|
...delayed ? {
|
|
4459
4777
|
wakeAt: startAtMs
|
|
4460
4778
|
} : {},
|
|
4461
|
-
namespace: opts?.namespace ?? this.namespace,
|
|
4779
|
+
namespace: poolNamespace(opts?.namespace) ?? this.namespace,
|
|
4462
4780
|
// DERIVED from the registration, exactly like `namespace` is derived from this engine — there is
|
|
4463
4781
|
// deliberately no `opts.origin`, so a caller cannot label its run as another package's work.
|
|
4464
4782
|
// `undefined` when the registration carried none (remote/convention/synthesized child, or a
|
|
@@ -4512,6 +4830,11 @@ var WorkflowEngine = class {
|
|
|
4512
4830
|
getRun(runId) {
|
|
4513
4831
|
return this.store.getRun(runId);
|
|
4514
4832
|
}
|
|
4833
|
+
/** A run's full checkpoint timeline, ordered by seq. A thin pass-through to the store (it is what
|
|
4834
|
+
* the testing kit's `captureHistory` reads, next to {@link getRun}). */
|
|
4835
|
+
listCheckpoints(runId) {
|
|
4836
|
+
return this.store.listCheckpoints(runId);
|
|
4837
|
+
}
|
|
4515
4838
|
/**
|
|
4516
4839
|
* Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
|
|
4517
4840
|
* driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
|
|
@@ -4597,6 +4920,8 @@ var WorkflowEngine = class {
|
|
|
4597
4920
|
* diagnosis.
|
|
4598
4921
|
*/
|
|
4599
4922
|
async releaseLeaseBestEffort(runId) {
|
|
4923
|
+
if (this.isFenced(runId)) return;
|
|
4924
|
+
this.handOffLease(runId);
|
|
4600
4925
|
try {
|
|
4601
4926
|
await this.store.releaseRunLock(runId);
|
|
4602
4927
|
} catch (err) {
|
|
@@ -4640,12 +4965,12 @@ var WorkflowEngine = class {
|
|
|
4640
4965
|
error: run.error
|
|
4641
4966
|
};
|
|
4642
4967
|
}
|
|
4643
|
-
if (this.namespace !== void 0 && run.namespace !== void 0 && run.namespace !== this.namespace) {
|
|
4968
|
+
if (this.namespace !== void 0 && run.namespace !== void 0 && poolNamespace(run.namespace) !== this.namespace) {
|
|
4644
4969
|
await this.store.releaseRunLock(runId);
|
|
4645
4970
|
throw new NamespaceMismatch();
|
|
4646
4971
|
}
|
|
4647
|
-
if (run.status === "failed" && run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
|
|
4648
|
-
run.tags = run.tags
|
|
4972
|
+
if (run.status === "failed" && (run.tags?.includes(SINGLETON_ADMITTED_TAG) || run.tags?.includes(SINGLETON_TRACKED_TAG))) {
|
|
4973
|
+
run.tags = withoutSingletonMarkers(run.tags);
|
|
4649
4974
|
await this.store.updateRun(run.id, {
|
|
4650
4975
|
tags: run.tags
|
|
4651
4976
|
});
|
|
@@ -4846,17 +5171,47 @@ var WorkflowEngine = class {
|
|
|
4846
5171
|
rearm();
|
|
4847
5172
|
});
|
|
4848
5173
|
this.drainRearms.add(rearm);
|
|
5174
|
+
let settled = false;
|
|
4849
5175
|
try {
|
|
4850
5176
|
await Promise.race([
|
|
4851
5177
|
Promise.allSettled([
|
|
4852
5178
|
...this.inflight
|
|
4853
|
-
])
|
|
5179
|
+
]).then(() => {
|
|
5180
|
+
settled = true;
|
|
5181
|
+
}),
|
|
4854
5182
|
timer
|
|
4855
5183
|
]);
|
|
4856
5184
|
} finally {
|
|
4857
5185
|
this.drainRearms.delete(rearm);
|
|
4858
5186
|
clearTimeout(timeout);
|
|
4859
5187
|
}
|
|
5188
|
+
if (!settled) await this.releaseInflightLeases();
|
|
5189
|
+
}
|
|
5190
|
+
/**
|
|
5191
|
+
* The drain budget ran out with executions still in flight: release their recovery leases so
|
|
5192
|
+
* another instance re-drives those runs on its next recovery pass, instead of waiting out
|
|
5193
|
+
* `leaseMs` after this process exits. Each still-running turn is FENCED first, so whatever it
|
|
5194
|
+
* computes after this point is never written over the new owner's work (see settleRun). Its
|
|
5195
|
+
* completed steps are checkpointed, so the new owner replays them; a step still executing here
|
|
5196
|
+
* may run again there, exactly as after a crash. The release is owner-scoped where the store
|
|
5197
|
+
* supports it (only a lease this instance still holds is cleared). Best effort: never throws.
|
|
5198
|
+
*/
|
|
5199
|
+
async releaseInflightLeases() {
|
|
5200
|
+
const released = [];
|
|
5201
|
+
for (const [runId, lease] of [
|
|
5202
|
+
...this.executionLeases
|
|
5203
|
+
]) {
|
|
5204
|
+
if (lease.lost || lease.handedOff || lease.done) continue;
|
|
5205
|
+
lease.lost = true;
|
|
5206
|
+
try {
|
|
5207
|
+
await this.store.releaseRunLock(runId, this.instanceId);
|
|
5208
|
+
released.push(runId);
|
|
5209
|
+
} catch {
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
if (released.length > 0) {
|
|
5213
|
+
console.warn(`[nestjs-durable] drain timed out with ${released.length} run(s) still executing; released their leases so another instance recovers them now: ${released.join(", ")}`);
|
|
5214
|
+
}
|
|
4860
5215
|
}
|
|
4861
5216
|
/**
|
|
4862
5217
|
* Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
|
|
@@ -4864,6 +5219,15 @@ var WorkflowEngine = class {
|
|
|
4864
5219
|
* `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
|
|
4865
5220
|
* are cascaded exactly as an explicit {@link cancel} cascades them.
|
|
4866
5221
|
*
|
|
5222
|
+
* WHICH RUNS. Every run not yet finished, counted from `createdAt`: `pending` and `blocked` as well
|
|
5223
|
+
* as `running` and `suspended`. A run nobody picks up (no worker on its queue) or nobody can run (no
|
|
5224
|
+
* worker with its capabilities) is as stuck as one that hangs mid-body, and the cap is the backstop
|
|
5225
|
+
* for exactly that — leaving them out meant a run that never started lived forever while one that
|
|
5226
|
+
* started and hung was cut off. Cancelling one a worker is picking up at that moment is safe: the
|
|
5227
|
+
* worker's settle only writes over a live status ({@link SETTLE_ELIGIBLE_STATUSES}), so it can't
|
|
5228
|
+
* bring the run back. `cancelling` is left alone on purpose — a compensating cancel's saga undo is
|
|
5229
|
+
* running, it ends `cancelled` by itself, and cutting it short would skip the undo.
|
|
5230
|
+
*
|
|
4867
5231
|
* WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
|
|
4868
5232
|
* the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
|
|
4869
5233
|
* a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
|
|
@@ -4876,8 +5240,8 @@ var WorkflowEngine = class {
|
|
|
4876
5240
|
* children kept running with the parent gone and nothing pointing at them — a silent orphan you
|
|
4877
5241
|
* could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
|
|
4878
5242
|
* walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
|
|
4879
|
-
* `cancel` returns early on a run that is already
|
|
4880
|
-
*
|
|
5243
|
+
* `cancel` returns early on a run that is already terminal (a child that beat the sweep to the
|
|
5244
|
+
* finish is never clobbered, and a re-cancel is a no-op), and it writes the
|
|
4881
5245
|
* parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
|
|
4882
5246
|
* second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
|
|
4883
5247
|
* the same terminal state twice and cascade into already-cancelled children — idempotent, not
|
|
@@ -4893,18 +5257,11 @@ var WorkflowEngine = class {
|
|
|
4893
5257
|
if (this.draining) return;
|
|
4894
5258
|
if (reg.executionTimeoutMs == null) continue;
|
|
4895
5259
|
const deadline = now - reg.executionTimeoutMs;
|
|
4896
|
-
const inflight =
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
}),
|
|
4902
|
-
...await this.store.listRuns({
|
|
4903
|
-
workflow: reg.name,
|
|
4904
|
-
status: "suspended",
|
|
4905
|
-
namespace: this.namespace
|
|
4906
|
-
})
|
|
4907
|
-
];
|
|
5260
|
+
const inflight = await this.store.listRuns({
|
|
5261
|
+
workflow: reg.name,
|
|
5262
|
+
statuses: TIMEOUT_SWEPT_STATUSES,
|
|
5263
|
+
namespace: this.namespace
|
|
5264
|
+
});
|
|
4908
5265
|
for (const run of inflight) {
|
|
4909
5266
|
if (this.draining) return;
|
|
4910
5267
|
if (run.createdAt.getTime() > deadline) continue;
|
|
@@ -4937,48 +5294,103 @@ var WorkflowEngine = class {
|
|
|
4937
5294
|
const results = [];
|
|
4938
5295
|
for (const listed of await this.orphanedRuns(nowMs)) {
|
|
4939
5296
|
if (this.draining) break;
|
|
4940
|
-
const
|
|
4941
|
-
if (!
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
5297
|
+
const outcome = this.isOwnLiveLease(listed, nowMs) ? await this.reclaimOwnLease(listed, nowMs) : await this.recoverOrphan(listed, nowMs);
|
|
5298
|
+
if (!outcome) continue;
|
|
5299
|
+
if (outcome.dispatch) await this.runDispatcher.dispatch(outcome.result.runId);
|
|
5300
|
+
results.push(outcome.result);
|
|
5301
|
+
}
|
|
5302
|
+
return results;
|
|
5303
|
+
}
|
|
5304
|
+
/** A run whose still-live lease is held by this very `instanceId` (see {@link reclaimOwnLease}). */
|
|
5305
|
+
isOwnLiveLease(run, nowMs) {
|
|
5306
|
+
return run.lockedBy === this.instanceId && (run.lockedUntil ?? 0) > nowMs;
|
|
5307
|
+
}
|
|
5308
|
+
/**
|
|
5309
|
+
* Recover a run whose lease is still live but held by THIS `instanceId` and predates this process
|
|
5310
|
+
* (listed by {@link orphanedRuns}): the previous process with this stable id died without a
|
|
5311
|
+
* graceful shutdown (OOM, SIGKILL), so nothing is renewing that lease and waiting for it to expire
|
|
5312
|
+
* only delays the run. The lease is taken back with `renewRunLock` (a compare-and-set on the
|
|
5313
|
+
* owner; `tryLockRun` refuses a live lease), so the lease alone cannot fence a live execution in
|
|
5314
|
+
* THIS process from its own recovery: the in-process run gate does. A run with a
|
|
5315
|
+
* gate is executing (or about to) here and is skipped; otherwise a gate is held while the run is
|
|
5316
|
+
* recovered, so a resume landing meanwhile queues behind the recovery instead of running beside it.
|
|
5317
|
+
* The gate is released before the re-dispatch, which may resume the run on this instance.
|
|
5318
|
+
*/
|
|
5319
|
+
async reclaimOwnLease(listed, nowMs) {
|
|
5320
|
+
if (this.runGates.has(listed.id)) return void 0;
|
|
5321
|
+
let release;
|
|
5322
|
+
const tail = new Promise((resolve) => {
|
|
5323
|
+
release = resolve;
|
|
5324
|
+
});
|
|
5325
|
+
const gate = {
|
|
5326
|
+
tail
|
|
5327
|
+
};
|
|
5328
|
+
this.runGates.set(listed.id, gate);
|
|
5329
|
+
this.closeGateAfter(listed.id, gate, tail);
|
|
5330
|
+
let outcome;
|
|
5331
|
+
try {
|
|
5332
|
+
const fresh = await this.store.getRun(listed.id);
|
|
5333
|
+
const liveHere = fresh?.lockedBy === this.instanceId && (fresh.lockedUntil ?? 0) > nowMs && (fresh.lockedUntil ?? 0) >= this.ownLeaseCutoffMs;
|
|
5334
|
+
if (fresh && !liveHere) outcome = await this.recoverOrphan(listed, nowMs, true);
|
|
5335
|
+
} finally {
|
|
5336
|
+
release(outcome?.result ?? {
|
|
5337
|
+
runId: listed.id,
|
|
5338
|
+
status: listed.status
|
|
5339
|
+
});
|
|
5340
|
+
}
|
|
5341
|
+
return outcome;
|
|
5342
|
+
}
|
|
5343
|
+
/**
|
|
5344
|
+
* One run of a {@link recoverIncomplete} pass: take its lease and decide on it. Resolves the
|
|
5345
|
+
* pass's result for it and whether it must be re-dispatched, or `undefined` when it was not ours.
|
|
5346
|
+
* `ownLease`: the run's live lease is this instance id's own, left by a previous process (see
|
|
5347
|
+
* {@link reclaimOwnLease}), so it is taken back by owner rather than acquired as a free one.
|
|
5348
|
+
*/
|
|
5349
|
+
async recoverOrphan(listed, nowMs, ownLease = false) {
|
|
5350
|
+
const acquired = ownLease ? await this.store.renewRunLock(listed.id, this.instanceId, nowMs + this.leaseMs) : await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
5351
|
+
if (!acquired) return void 0;
|
|
5352
|
+
const run = await this.store.getRun(listed.id);
|
|
5353
|
+
if (!run || run.status !== "running" && run.status !== "cancelling") {
|
|
5354
|
+
await this.store.releaseRunLock(listed.id);
|
|
5355
|
+
return void 0;
|
|
5356
|
+
}
|
|
5357
|
+
if (run.status === "running") {
|
|
5358
|
+
const dispatched = await this.inFlightRemoteStep(run.id);
|
|
5359
|
+
if (dispatched) {
|
|
4970
5360
|
await this.store.updateRun(run.id, {
|
|
4971
|
-
status: "
|
|
5361
|
+
status: "suspended",
|
|
5362
|
+
wakeAt: this.reconcileWakeAt(dispatched.wakeAt ?? void 0),
|
|
4972
5363
|
updatedAt: /* @__PURE__ */ new Date()
|
|
4973
5364
|
});
|
|
5365
|
+
await this.store.releaseRunLock(run.id);
|
|
5366
|
+
return {
|
|
5367
|
+
result: {
|
|
5368
|
+
runId: run.id,
|
|
5369
|
+
status: "suspended"
|
|
5370
|
+
},
|
|
5371
|
+
dispatch: false
|
|
5372
|
+
};
|
|
4974
5373
|
}
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
5374
|
+
}
|
|
5375
|
+
const settled = await this.countRecovery(run);
|
|
5376
|
+
if (settled) return {
|
|
5377
|
+
result: settled,
|
|
5378
|
+
dispatch: false
|
|
5379
|
+
};
|
|
5380
|
+
await this.store.releaseRunLock(run.id);
|
|
5381
|
+
if (run.status !== "cancelling") {
|
|
5382
|
+
await this.store.updateRun(run.id, {
|
|
5383
|
+
status: "pending",
|
|
5384
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
4979
5385
|
});
|
|
4980
5386
|
}
|
|
4981
|
-
return
|
|
5387
|
+
return {
|
|
5388
|
+
result: {
|
|
5389
|
+
runId: run.id,
|
|
5390
|
+
status: run.status === "cancelling" ? "cancelling" : "pending"
|
|
5391
|
+
},
|
|
5392
|
+
dispatch: true
|
|
5393
|
+
};
|
|
4982
5394
|
}
|
|
4983
5395
|
/**
|
|
4984
5396
|
* The runs {@link recoverIncomplete} should try to reclaim: incomplete runs whose lease is free or
|
|
@@ -4988,12 +5400,20 @@ var WorkflowEngine = class {
|
|
|
4988
5400
|
* ({@link StateStore.listOrphanedRuns}, bounded by {@link RECOVERY_BATCH}); else the full list is
|
|
4989
5401
|
* filtered here, which still spares the probes. Shuffled, like the due-timer pass, so instances
|
|
4990
5402
|
* recovering at once spread over the batch instead of contending for the same leases.
|
|
5403
|
+
*
|
|
5404
|
+
* Also listed: runs still leased by THIS `instanceId` with a lease taken before this process
|
|
5405
|
+
* booted ({@link ownLeaseCutoffMs}). With a stable `instanceId` (pinned to the pod identity), that
|
|
5406
|
+
* is the previous process, killed without a graceful shutdown; see {@link reclaimOwnLease}.
|
|
4991
5407
|
*/
|
|
4992
5408
|
async orphanedRuns(nowMs) {
|
|
5409
|
+
const ownedBy = {
|
|
5410
|
+
owner: this.instanceId,
|
|
5411
|
+
lockedBefore: this.ownLeaseCutoffMs
|
|
5412
|
+
};
|
|
4993
5413
|
if (this.store.listOrphanedRuns) {
|
|
4994
|
-
return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace));
|
|
5414
|
+
return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace, ownedBy));
|
|
4995
5415
|
}
|
|
4996
|
-
return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, RECOVERY_BATCH));
|
|
5416
|
+
return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs || run.lockedBy === ownedBy.owner && run.lockedUntil < ownedBy.lockedBefore).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, RECOVERY_BATCH));
|
|
4997
5417
|
}
|
|
4998
5418
|
/**
|
|
4999
5419
|
* The remote step this run dispatched and is still awaiting a result for, if any — the signature
|
|
@@ -5036,6 +5456,7 @@ var WorkflowEngine = class {
|
|
|
5036
5456
|
error: error2,
|
|
5037
5457
|
recoveryAttempts: attempts
|
|
5038
5458
|
});
|
|
5459
|
+
this.notifyParent(run.id, failedCompletion(error2));
|
|
5039
5460
|
return {
|
|
5040
5461
|
runId: run.id,
|
|
5041
5462
|
status: "dead",
|
|
@@ -5242,7 +5663,7 @@ var WorkflowEngine = class {
|
|
|
5242
5663
|
error: void 0,
|
|
5243
5664
|
...resurrecting ? {
|
|
5244
5665
|
recoveryAttempts: 0,
|
|
5245
|
-
tags: run.tags
|
|
5666
|
+
tags: withoutSingletonMarkers(run.tags)
|
|
5246
5667
|
} : {},
|
|
5247
5668
|
updatedAt: /* @__PURE__ */ new Date()
|
|
5248
5669
|
});
|
|
@@ -5654,6 +6075,16 @@ var WorkflowEngine = class {
|
|
|
5654
6075
|
return this.deliverSignal(lateWaiter, reclaimed.payload);
|
|
5655
6076
|
}
|
|
5656
6077
|
/**
|
|
6078
|
+
* Wake the run parked on `token` with `payload`, if there is one; otherwise do nothing — unlike
|
|
6079
|
+
* {@link signal}, it never buffers. For a wake-up whose real payload is ALREADY buffered on the
|
|
6080
|
+
* token (an entity op, see `Entities.kick`): a run that isn't parked reads that buffer at its next
|
|
6081
|
+
* wait, so there is nothing to keep.
|
|
6082
|
+
*/
|
|
6083
|
+
async wakeSignalWaiter(token, payload) {
|
|
6084
|
+
const waiter = await this.store.takeSignalWaiter(token);
|
|
6085
|
+
return waiter ? this.deliverSignal(waiter, payload) : null;
|
|
6086
|
+
}
|
|
6087
|
+
/**
|
|
5657
6088
|
* Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
|
|
5658
6089
|
* signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
|
|
5659
6090
|
* consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
|
|
@@ -6249,9 +6680,31 @@ var WorkflowEngine = class {
|
|
|
6249
6680
|
});
|
|
6250
6681
|
}
|
|
6251
6682
|
/** Admission persists the wait atomically; never overwrite a cancellation after it returns. */
|
|
6683
|
+
/**
|
|
6684
|
+
* A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
|
|
6685
|
+
* {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
|
|
6686
|
+
* replica is about to admit or deny is never mistaken for a holder admitted by a replica older
|
|
6687
|
+
* than 0.73 (see `legacySingletonHolders`).
|
|
6688
|
+
*/
|
|
6689
|
+
async markStarted(run, singleton) {
|
|
6690
|
+
const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
|
|
6691
|
+
const retag = tags !== run.tags;
|
|
6692
|
+
await this.store.updateRun(run.id, {
|
|
6693
|
+
status: "running",
|
|
6694
|
+
...retag ? {
|
|
6695
|
+
tags
|
|
6696
|
+
} : {},
|
|
6697
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
6698
|
+
});
|
|
6699
|
+
run.status = "running";
|
|
6700
|
+
if (retag) run.tags = tags;
|
|
6701
|
+
}
|
|
6252
6702
|
async singletonWaitResult(run) {
|
|
6253
6703
|
const current = await this.store.getRun(run.id);
|
|
6254
|
-
|
|
6704
|
+
if (!this.isFenced(run.id)) {
|
|
6705
|
+
this.handOffLease(run.id);
|
|
6706
|
+
await this.store.releaseRunLock(run.id);
|
|
6707
|
+
}
|
|
6255
6708
|
if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
|
|
6256
6709
|
if (current.status === "suspended") {
|
|
6257
6710
|
this.emit({
|
|
@@ -6279,10 +6732,7 @@ var WorkflowEngine = class {
|
|
|
6279
6732
|
};
|
|
6280
6733
|
}
|
|
6281
6734
|
}
|
|
6282
|
-
const
|
|
6283
|
-
void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
6284
|
-
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
6285
|
-
renew.unref?.();
|
|
6735
|
+
const held = this.holdExecutionLease(run.id);
|
|
6286
6736
|
try {
|
|
6287
6737
|
const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
|
|
6288
6738
|
if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
|
|
@@ -6290,10 +6740,59 @@ var WorkflowEngine = class {
|
|
|
6290
6740
|
}
|
|
6291
6741
|
return result;
|
|
6292
6742
|
} finally {
|
|
6293
|
-
|
|
6743
|
+
held.stop();
|
|
6294
6744
|
}
|
|
6295
6745
|
}
|
|
6296
6746
|
/**
|
|
6747
|
+
* Renew `runId`'s lease for as long as this execution holds it, and register it in
|
|
6748
|
+
* {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
|
|
6749
|
+
*
|
|
6750
|
+
* A renewal that returns `false` means the lease is gone: this turn stalled past `leaseMs` and
|
|
6751
|
+
* another instance reclaimed the run, so this executor is now a zombie. It is FENCED (`lost`): its
|
|
6752
|
+
* settles degrade to an echo of the store's state instead of clobbering the new owner's writes, and
|
|
6753
|
+
* its own lease release is skipped (it would free the new owner's lease). A renewal that THROWS (a
|
|
6754
|
+
* transient store error) is not evidence of a takeover, so it stays swallowed and the next
|
|
6755
|
+
* interval retries. A lease this execution released on purpose (`handedOff`) is not renewed, and a
|
|
6756
|
+
* renewal resolving after the execution ended (`done`) fences nothing.
|
|
6757
|
+
*/
|
|
6758
|
+
holdExecutionLease(runId) {
|
|
6759
|
+
const lease = {
|
|
6760
|
+
lost: false,
|
|
6761
|
+
handedOff: false,
|
|
6762
|
+
done: false
|
|
6763
|
+
};
|
|
6764
|
+
this.executionLeases.set(runId, lease);
|
|
6765
|
+
const renew = setInterval(() => {
|
|
6766
|
+
if (lease.handedOff || lease.lost || lease.done) return;
|
|
6767
|
+
void this.store.renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs).then((renewed) => {
|
|
6768
|
+
if (renewed || lease.handedOff || lease.lost || lease.done) return;
|
|
6769
|
+
lease.lost = true;
|
|
6770
|
+
console.warn(`[nestjs-durable] lost the lease of run ${runId} while executing it (another instance took it over after it expired); this instance will not write the run's outcome`);
|
|
6771
|
+
}).catch(() => void 0);
|
|
6772
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
6773
|
+
renew.unref?.();
|
|
6774
|
+
return {
|
|
6775
|
+
lease,
|
|
6776
|
+
stop: /* @__PURE__ */ __name(() => {
|
|
6777
|
+
clearInterval(renew);
|
|
6778
|
+
lease.done = true;
|
|
6779
|
+
if (this.executionLeases.get(runId) === lease) this.executionLeases.delete(runId);
|
|
6780
|
+
}, "stop")
|
|
6781
|
+
};
|
|
6782
|
+
}
|
|
6783
|
+
/** Whether `runId`'s execution on this instance lost its lease (see {@link holdExecutionLease}). */
|
|
6784
|
+
isFenced(runId) {
|
|
6785
|
+
return this.executionLeases.get(runId)?.lost === true;
|
|
6786
|
+
}
|
|
6787
|
+
/**
|
|
6788
|
+
* Mark `runId`'s lease as given away on purpose by its own execution (released mid-turn), so a
|
|
6789
|
+
* renewal that now fails is not read as a takeover.
|
|
6790
|
+
*/
|
|
6791
|
+
handOffLease(runId) {
|
|
6792
|
+
const lease = this.executionLeases.get(runId);
|
|
6793
|
+
if (lease) lease.handedOff = true;
|
|
6794
|
+
}
|
|
6795
|
+
/**
|
|
6297
6796
|
* Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
|
|
6298
6797
|
* a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
|
|
6299
6798
|
* {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
|
|
@@ -6510,6 +7009,7 @@ ${run.workflowVersion}`;
|
|
|
6510
7009
|
* keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
|
|
6511
7010
|
*/
|
|
6512
7011
|
async settleRun(run, outcome) {
|
|
7012
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6513
7013
|
const updatedAt = /* @__PURE__ */ new Date();
|
|
6514
7014
|
if (outcome.kind === "completed") {
|
|
6515
7015
|
const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
|
|
@@ -6590,6 +7090,7 @@ ${run.workflowVersion}`;
|
|
|
6590
7090
|
}
|
|
6591
7091
|
async runRemoteExecution(run, registered) {
|
|
6592
7092
|
if (run.status === "cancelling") {
|
|
7093
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6593
7094
|
const error2 = {
|
|
6594
7095
|
message: "cancelled"
|
|
6595
7096
|
};
|
|
@@ -6598,6 +7099,7 @@ ${run.workflowVersion}`;
|
|
|
6598
7099
|
error: error2,
|
|
6599
7100
|
updatedAt: /* @__PURE__ */ new Date()
|
|
6600
7101
|
});
|
|
7102
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6601
7103
|
this.emit({
|
|
6602
7104
|
type: "run.failed",
|
|
6603
7105
|
runId: run.id,
|
|
@@ -6622,11 +7124,7 @@ ${run.workflowVersion}`;
|
|
|
6622
7124
|
}
|
|
6623
7125
|
const remote = registered.remote;
|
|
6624
7126
|
if (run.status === "pending") {
|
|
6625
|
-
await this.
|
|
6626
|
-
status: "running",
|
|
6627
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
6628
|
-
});
|
|
6629
|
-
run.status = "running";
|
|
7127
|
+
await this.markStarted(run, registered?.singleton !== void 0);
|
|
6630
7128
|
this.emit({
|
|
6631
7129
|
type: "run.started",
|
|
6632
7130
|
runId: run.id,
|
|
@@ -6648,6 +7146,7 @@ ${run.workflowVersion}`;
|
|
|
6648
7146
|
}
|
|
6649
7147
|
const history = await this.remoteHistory(run.id, checkpoints);
|
|
6650
7148
|
if (remote.executor.dispatch) {
|
|
7149
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6651
7150
|
const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
6652
7151
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
6653
7152
|
const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
|
|
@@ -6663,6 +7162,7 @@ ${run.workflowVersion}`;
|
|
|
6663
7162
|
workflow: run.workflow,
|
|
6664
7163
|
namespace: run.namespace
|
|
6665
7164
|
});
|
|
7165
|
+
this.handOffLease(run.id);
|
|
6666
7166
|
await this.store.releaseRunLock(run.id);
|
|
6667
7167
|
await remote.executor.dispatch(run, history, taskId);
|
|
6668
7168
|
return {
|
|
@@ -6691,6 +7191,7 @@ ${run.workflowVersion}`;
|
|
|
6691
7191
|
message: err instanceof Error ? err.message : String(err),
|
|
6692
7192
|
stack: err instanceof Error ? err.stack : void 0
|
|
6693
7193
|
};
|
|
7194
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6694
7195
|
await this.store.updateRun(run.id, {
|
|
6695
7196
|
status: "failed",
|
|
6696
7197
|
error: error2,
|
|
@@ -6719,6 +7220,7 @@ ${run.workflowVersion}`;
|
|
|
6719
7220
|
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
6720
7221
|
*/
|
|
6721
7222
|
async applyDecision(run, decision) {
|
|
7223
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6722
7224
|
if (decision.status === "completed") {
|
|
6723
7225
|
await this.applyCommands(run, decision.commands);
|
|
6724
7226
|
return this.settleRun(run, {
|
|
@@ -7188,6 +7690,7 @@ ${run.workflowVersion}`;
|
|
|
7188
7690
|
* cancel flag stays too, re-derived from the status on any later re-drive regardless.
|
|
7189
7691
|
*/
|
|
7190
7692
|
async compensateAndCancel(run, compensations, replay) {
|
|
7693
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
7191
7694
|
const pending = await this.unwindCompensations(run, compensations, replay);
|
|
7192
7695
|
if (pending) return {
|
|
7193
7696
|
runId: run.id,
|
|
@@ -7242,11 +7745,7 @@ ${run.workflowVersion}`;
|
|
|
7242
7745
|
async runExecutionTurn(run, fn, registered) {
|
|
7243
7746
|
if (run.status === "cancelling") this.cancelRequested.add(run.id);
|
|
7244
7747
|
if (run.status === "pending") {
|
|
7245
|
-
await this.
|
|
7246
|
-
status: "running",
|
|
7247
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
7248
|
-
});
|
|
7249
|
-
run.status = "running";
|
|
7748
|
+
await this.markStarted(run, registered?.singleton !== void 0);
|
|
7250
7749
|
this.emit({
|
|
7251
7750
|
type: "run.started",
|
|
7252
7751
|
runId: run.id,
|
|
@@ -7283,6 +7782,7 @@ ${run.workflowVersion}`;
|
|
|
7283
7782
|
});
|
|
7284
7783
|
} catch (err) {
|
|
7285
7784
|
if (err instanceof ContinueAsNew) {
|
|
7785
|
+
if (this.isFenced(run.id)) return await this.echoCurrentStatus(run.id);
|
|
7286
7786
|
if (this.cancelRequested.has(run.id)) {
|
|
7287
7787
|
return await this.compensateAndCancel(run, compensations, replay);
|
|
7288
7788
|
}
|
|
@@ -7622,6 +8122,17 @@ ${run.workflowVersion}`;
|
|
|
7622
8122
|
signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
|
|
7623
8123
|
queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
|
|
7624
8124
|
}, "signalEntity"),
|
|
8125
|
+
// The durable half of a workflow-side entity send: awaited inside the turn (a store write
|
|
8126
|
+
// only, never an inline entity turn), so the ctx can checkpoint after it. See Entities.enqueue.
|
|
8127
|
+
enqueueEntityOp: /* @__PURE__ */ __name((name, key, op, arg, id, reply) => this.entities.enqueue(name, key, op, arg, id, reply), "enqueueEntityOp"),
|
|
8128
|
+
// Start/wake the entity for what enqueueEntityOp buffered — deferred like signalEntity above,
|
|
8129
|
+
// so it never re-enters a still-executing turn. A failed wake loses nothing: the op stays
|
|
8130
|
+
// buffered for the entity's next wait (and recovery's buffered-entity wake).
|
|
8131
|
+
kickEntity: /* @__PURE__ */ __name((name, key) => {
|
|
8132
|
+
queueMicrotask(() => void this.entities.kick(name, key).catch((err) => {
|
|
8133
|
+
console.warn(`[nestjs-durable] waking entity "${name}" key "${key}" failed (its op stays buffered for the next wake):`, err);
|
|
8134
|
+
}));
|
|
8135
|
+
}, "kickEntity"),
|
|
7625
8136
|
interceptStep: /* @__PURE__ */ __name((invocation, body) => this.interceptStep(invocation, body), "interceptStep")
|
|
7626
8137
|
};
|
|
7627
8138
|
}
|
|
@@ -7723,6 +8234,7 @@ ${run.workflowVersion}`;
|
|
|
7723
8234
|
* own `finally` also releases (the run-execution path) is safe.
|
|
7724
8235
|
*/
|
|
7725
8236
|
async parkBlocked(run, blocked) {
|
|
8237
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
7726
8238
|
const error2 = {
|
|
7727
8239
|
message: blocked.reason,
|
|
7728
8240
|
code: blocked.code,
|
|
@@ -7763,7 +8275,7 @@ ${run.workflowVersion}`;
|
|
|
7763
8275
|
"blocked"
|
|
7764
8276
|
]
|
|
7765
8277
|
});
|
|
7766
|
-
return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || r.namespace === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
|
|
8278
|
+
return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || poolNamespace(r.namespace) === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
|
|
7767
8279
|
}
|
|
7768
8280
|
async callRemote(runId, seq, step, input, queue, transport, replay, admission, routing) {
|
|
7769
8281
|
const group = stepGroup(step, routing?.namespace);
|
|
@@ -8302,17 +8814,14 @@ ${run.workflowVersion}`;
|
|
|
8302
8814
|
const run = await this.store.getRun(decision.runId);
|
|
8303
8815
|
if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
|
|
8304
8816
|
if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
|
|
8305
|
-
if (this.namespace !== void 0 && run.namespace !== void 0 && run.namespace !== this.namespace) {
|
|
8817
|
+
if (this.namespace !== void 0 && run.namespace !== void 0 && poolNamespace(run.namespace) !== this.namespace) {
|
|
8306
8818
|
return;
|
|
8307
8819
|
}
|
|
8308
8820
|
const nowMs = this.clock();
|
|
8309
8821
|
if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
|
|
8310
8822
|
return;
|
|
8311
8823
|
}
|
|
8312
|
-
const
|
|
8313
|
-
void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
8314
|
-
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
8315
|
-
renew.unref?.();
|
|
8824
|
+
const held = this.holdExecutionLease(decision.runId);
|
|
8316
8825
|
try {
|
|
8317
8826
|
const fresh = await this.store.getRun(decision.runId);
|
|
8318
8827
|
if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
|
|
@@ -8327,8 +8836,9 @@ ${run.workflowVersion}`;
|
|
|
8327
8836
|
fresh.awaitingDecisionTaskId = void 0;
|
|
8328
8837
|
await this.applyDecision(fresh, decision);
|
|
8329
8838
|
} finally {
|
|
8330
|
-
|
|
8331
|
-
|
|
8839
|
+
const fenced = held.lease.lost;
|
|
8840
|
+
held.stop();
|
|
8841
|
+
if (!fenced) await this.store.releaseRunLock(decision.runId);
|
|
8332
8842
|
}
|
|
8333
8843
|
}
|
|
8334
8844
|
/** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
|
|
@@ -8524,6 +9034,9 @@ function childIdsOf(checkpoints) {
|
|
|
8524
9034
|
}
|
|
8525
9035
|
__name(childIdsOf, "childIdsOf");
|
|
8526
9036
|
|
|
9037
|
+
// src/run-history.ts
|
|
9038
|
+
var RUN_HISTORY_FIXTURE_VERSION = 1;
|
|
9039
|
+
|
|
8527
9040
|
// src/run-facets.ts
|
|
8528
9041
|
function facetOrigin(origin) {
|
|
8529
9042
|
return origin?.trim() ? origin : null;
|
|
@@ -9077,13 +9590,14 @@ var CodecStateStore = class {
|
|
|
9077
9590
|
}
|
|
9078
9591
|
inner;
|
|
9079
9592
|
codec;
|
|
9593
|
+
extended;
|
|
9080
9594
|
/** Atomic claims touch only searchable metadata and bypass payload encoding. */
|
|
9081
9595
|
tryAdmitSingleton;
|
|
9082
9596
|
/**
|
|
9083
9597
|
* Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
|
|
9084
|
-
* (it encodes payloads: input/output/error). Bound as a
|
|
9085
|
-
* actually aggregates, so `store.runFacets?.()` still
|
|
9086
|
-
* of resolving to a silently wrong empty result.
|
|
9598
|
+
* (it encodes payloads: input/output, plus events/error under `extended` coverage). Bound as a
|
|
9599
|
+
* PROPERTY, and only when the inner store actually aggregates, so `store.runFacets?.()` still
|
|
9600
|
+
* reports absence through the wrapper instead of resolving to a silently wrong empty result.
|
|
9087
9601
|
*/
|
|
9088
9602
|
runFacets;
|
|
9089
9603
|
/**
|
|
@@ -9135,14 +9649,15 @@ var CodecStateStore = class {
|
|
|
9135
9649
|
updateSchedule;
|
|
9136
9650
|
deleteSchedule;
|
|
9137
9651
|
listSchedules;
|
|
9138
|
-
constructor(inner, codec) {
|
|
9652
|
+
constructor(inner, codec, options = {}) {
|
|
9139
9653
|
this.inner = inner;
|
|
9140
9654
|
this.codec = codec;
|
|
9655
|
+
this.extended = options.coverage === "extended";
|
|
9141
9656
|
if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
|
|
9142
9657
|
const deleteRuns = inner.deleteRuns;
|
|
9143
9658
|
if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
|
|
9144
9659
|
const orphans = inner.listOrphanedRuns;
|
|
9145
|
-
if (orphans) this.listOrphanedRuns = async (nowMs, limit, namespace) => (await orphans.call(inner, nowMs, limit, namespace)).map((r) => this.decRun(r));
|
|
9660
|
+
if (orphans) this.listOrphanedRuns = async (nowMs, limit, namespace, ownedBy) => (await orphans.call(inner, nowMs, limit, namespace, ownedBy)).map((r) => this.decRun(r));
|
|
9146
9661
|
const updateIf = inner.updateRunIf;
|
|
9147
9662
|
if (updateIf) this.updateRunIf = (runId, expected, patch) => updateIf.call(inner, runId, expected, this.encRunPatch(patch));
|
|
9148
9663
|
const saveIfPending = inner.saveCheckpointIfPending;
|
|
@@ -9195,32 +9710,52 @@ var CodecStateStore = class {
|
|
|
9195
9710
|
return v === void 0 ? void 0 : this.codec.decode(v);
|
|
9196
9711
|
}
|
|
9197
9712
|
encRun(run) {
|
|
9198
|
-
|
|
9713
|
+
const next = {
|
|
9199
9714
|
...run,
|
|
9200
9715
|
input: this.enc(run.input),
|
|
9201
9716
|
output: this.enc(run.output)
|
|
9202
9717
|
};
|
|
9718
|
+
if (this.extended && run.error !== void 0) {
|
|
9719
|
+
next.error = this.enc(run.error);
|
|
9720
|
+
}
|
|
9721
|
+
return next;
|
|
9203
9722
|
}
|
|
9204
9723
|
decRun(run) {
|
|
9205
|
-
|
|
9724
|
+
if (!run) return run;
|
|
9725
|
+
const next = {
|
|
9206
9726
|
...run,
|
|
9207
9727
|
input: this.dec(run.input),
|
|
9208
9728
|
output: this.dec(run.output)
|
|
9209
9729
|
};
|
|
9730
|
+
if (this.extended && run.error !== void 0) {
|
|
9731
|
+
next.error = this.dec(run.error);
|
|
9732
|
+
}
|
|
9733
|
+
return next;
|
|
9210
9734
|
}
|
|
9211
9735
|
encCp(cp) {
|
|
9212
|
-
|
|
9736
|
+
const next = {
|
|
9213
9737
|
...cp,
|
|
9214
9738
|
input: this.enc(cp.input),
|
|
9215
9739
|
output: this.enc(cp.output)
|
|
9216
9740
|
};
|
|
9741
|
+
if (this.extended) {
|
|
9742
|
+
if (cp.events !== void 0) next.events = this.enc(cp.events);
|
|
9743
|
+
if (cp.error !== void 0) next.error = this.enc(cp.error);
|
|
9744
|
+
}
|
|
9745
|
+
return next;
|
|
9217
9746
|
}
|
|
9218
9747
|
decCp(cp) {
|
|
9219
|
-
|
|
9748
|
+
if (!cp) return cp;
|
|
9749
|
+
const next = {
|
|
9220
9750
|
...cp,
|
|
9221
9751
|
input: this.dec(cp.input),
|
|
9222
9752
|
output: this.dec(cp.output)
|
|
9223
9753
|
};
|
|
9754
|
+
if (this.extended) {
|
|
9755
|
+
if (cp.events !== void 0) next.events = this.dec(cp.events);
|
|
9756
|
+
if (cp.error !== void 0) next.error = this.dec(cp.error);
|
|
9757
|
+
}
|
|
9758
|
+
return next;
|
|
9224
9759
|
}
|
|
9225
9760
|
encSpec(spec) {
|
|
9226
9761
|
return "input" in spec ? {
|
|
@@ -9259,6 +9794,9 @@ var CodecStateStore = class {
|
|
|
9259
9794
|
};
|
|
9260
9795
|
if ("input" in patch) next.input = this.enc(patch.input);
|
|
9261
9796
|
if ("output" in patch) next.output = this.enc(patch.output);
|
|
9797
|
+
if (this.extended && patch.error !== void 0) {
|
|
9798
|
+
next.error = this.enc(patch.error);
|
|
9799
|
+
}
|
|
9262
9800
|
return next;
|
|
9263
9801
|
}
|
|
9264
9802
|
async getRun(runId) {
|
|
@@ -9294,8 +9832,8 @@ var CodecStateStore = class {
|
|
|
9294
9832
|
tryLockRun(runId, owner, leaseUntilMs, nowMs) {
|
|
9295
9833
|
return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
|
|
9296
9834
|
}
|
|
9297
|
-
releaseRunLock(runId) {
|
|
9298
|
-
return this.inner.releaseRunLock(runId);
|
|
9835
|
+
releaseRunLock(runId, owner) {
|
|
9836
|
+
return owner === void 0 ? this.inner.releaseRunLock(runId) : this.inner.releaseRunLock(runId, owner);
|
|
9299
9837
|
}
|
|
9300
9838
|
renewRunLock(runId, owner, leaseUntilMs) {
|
|
9301
9839
|
return this.inner.renewRunLock(runId, owner, leaseUntilMs);
|
|
@@ -9663,7 +10201,7 @@ var InMemoryStateStore = class {
|
|
|
9663
10201
|
async createRun(run) {
|
|
9664
10202
|
this.runs.set(run.id, {
|
|
9665
10203
|
...run,
|
|
9666
|
-
namespace: run.namespace
|
|
10204
|
+
namespace: run.namespace || "default"
|
|
9667
10205
|
});
|
|
9668
10206
|
this.reindexAttributes(run.id, run.searchAttributes);
|
|
9669
10207
|
}
|
|
@@ -9678,7 +10216,7 @@ var InMemoryStateStore = class {
|
|
|
9678
10216
|
applyRunPatch(runId, inputPatch) {
|
|
9679
10217
|
const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
|
|
9680
10218
|
...inputPatch,
|
|
9681
|
-
tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)
|
|
10219
|
+
tags: withoutSingletonMarkers("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)
|
|
9682
10220
|
} : inputPatch;
|
|
9683
10221
|
const existing = this.runs.get(runId);
|
|
9684
10222
|
if (!existing) throw new Error(`run ${runId} not found`);
|
|
@@ -9766,10 +10304,10 @@ var InMemoryStateStore = class {
|
|
|
9766
10304
|
return true;
|
|
9767
10305
|
});
|
|
9768
10306
|
}
|
|
9769
|
-
async listOrphanedRuns(nowMs, limit, namespace) {
|
|
10307
|
+
async listOrphanedRuns(nowMs, limit, namespace, ownedBy) {
|
|
9770
10308
|
return [
|
|
9771
10309
|
...this.runs.values()
|
|
9772
|
-
].filter((r) => (r.status === "running" || r.status === "cancelling") && (r.lockedUntil == null || r.lockedUntil <= nowMs) && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, Math.max(0, limit)).map((r) => ({
|
|
10310
|
+
].filter((r) => (r.status === "running" || r.status === "cancelling") && (r.lockedUntil == null || r.lockedUntil <= nowMs || ownedBy !== void 0 && r.lockedBy === ownedBy.owner && r.lockedUntil < ownedBy.lockedBefore) && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, Math.max(0, limit)).map((r) => ({
|
|
9773
10311
|
...r
|
|
9774
10312
|
}));
|
|
9775
10313
|
}
|
|
@@ -9803,9 +10341,9 @@ var InMemoryStateStore = class {
|
|
|
9803
10341
|
run.lockedUntil = leaseUntilMs;
|
|
9804
10342
|
return true;
|
|
9805
10343
|
}
|
|
9806
|
-
async releaseRunLock(runId) {
|
|
10344
|
+
async releaseRunLock(runId, owner) {
|
|
9807
10345
|
const run = this.runs.get(runId);
|
|
9808
|
-
if (run) {
|
|
10346
|
+
if (run && (owner === void 0 || run.lockedBy === owner)) {
|
|
9809
10347
|
run.lockedBy = void 0;
|
|
9810
10348
|
run.lockedUntil = void 0;
|
|
9811
10349
|
}
|
|
@@ -10155,12 +10693,14 @@ export {
|
|
|
10155
10693
|
ContinueAsNew,
|
|
10156
10694
|
DEFAULT_CONCURRENCY_STATUSES,
|
|
10157
10695
|
DEFAULT_PARTITION,
|
|
10696
|
+
DEFAULT_STALLED_AFTER_MS,
|
|
10158
10697
|
DURABLE_OPTIONS,
|
|
10159
10698
|
DURABLE_OPTIONS_CANONICAL,
|
|
10160
10699
|
DURABLE_STEP_CONFIG,
|
|
10161
10700
|
DURABLE_STEP_NAME,
|
|
10162
10701
|
DurableWorkflow,
|
|
10163
10702
|
ENGINE_MINTED_TAG_PREFIXES,
|
|
10703
|
+
ENTITY_WORKFLOW,
|
|
10164
10704
|
EVENT_BUFFER_SCAN_LIMIT,
|
|
10165
10705
|
EVENT_TOKEN_PREFIX,
|
|
10166
10706
|
Entities,
|
|
@@ -10177,6 +10717,7 @@ export {
|
|
|
10177
10717
|
NestedWorkflowCallError,
|
|
10178
10718
|
NonDeterminismError,
|
|
10179
10719
|
QueueController,
|
|
10720
|
+
RUN_HISTORY_FIXTURE_VERSION,
|
|
10180
10721
|
RUN_VALUE_FACET_LIMIT,
|
|
10181
10722
|
RUN_VALUE_FACET_SCAN,
|
|
10182
10723
|
RemoteStepError,
|
|
@@ -10186,6 +10727,8 @@ export {
|
|
|
10186
10727
|
RunGateway,
|
|
10187
10728
|
SINGLETON_ACTIVE_STATUSES,
|
|
10188
10729
|
SINGLETON_ADMITTED_TAG,
|
|
10730
|
+
SINGLETON_RESERVED_TAGS,
|
|
10731
|
+
SINGLETON_TRACKED_TAG,
|
|
10189
10732
|
STATE_STORE,
|
|
10190
10733
|
STATE_STORE_CANONICAL,
|
|
10191
10734
|
SUMMARY_CHILD_LIMIT,
|
|
@@ -10248,6 +10791,7 @@ export {
|
|
|
10248
10791
|
facetOrigin,
|
|
10249
10792
|
fanPageBounds,
|
|
10250
10793
|
fanPageEtag,
|
|
10794
|
+
findStalledRuns,
|
|
10251
10795
|
foldedCount,
|
|
10252
10796
|
handoffHandlerId,
|
|
10253
10797
|
handoffName,
|
|
@@ -10261,6 +10805,7 @@ export {
|
|
|
10261
10805
|
isLegacyDescriptor,
|
|
10262
10806
|
isNonEmptyRunScope,
|
|
10263
10807
|
isWorkflowControlFlowSignal,
|
|
10808
|
+
legacySingletonHolders,
|
|
10264
10809
|
listSignalWaitersOfRuns,
|
|
10265
10810
|
liveHandoff,
|
|
10266
10811
|
log,
|
|
@@ -10278,8 +10823,10 @@ export {
|
|
|
10278
10823
|
pageFanFromStore,
|
|
10279
10824
|
parentRunIdOf,
|
|
10280
10825
|
parseDuration,
|
|
10826
|
+
parseWebhookToken,
|
|
10281
10827
|
partitionOfToken,
|
|
10282
10828
|
planDispatch,
|
|
10829
|
+
poolNamespace,
|
|
10283
10830
|
prevCronFireMs,
|
|
10284
10831
|
queryCheckpointsOf,
|
|
10285
10832
|
readSearchAttributes,
|
|
@@ -10315,6 +10862,8 @@ export {
|
|
|
10315
10862
|
tenantGroup,
|
|
10316
10863
|
useDurableExecution,
|
|
10317
10864
|
warn,
|
|
10865
|
+
withSingletonTracked,
|
|
10866
|
+
withoutSingletonMarkers,
|
|
10318
10867
|
workflowName
|
|
10319
10868
|
};
|
|
10320
10869
|
//# sourceMappingURL=index.js.map
|