@dudousxd/nestjs-durable-core 0.52.0 → 0.54.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 +330 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +187 -1
- package/dist/index.d.ts +187 -1
- package/dist/index.js +327 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,8 @@ __export(index_exports, {
|
|
|
30
30
|
DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
|
|
31
31
|
DURABLE_STEP_NAME: () => DURABLE_STEP_NAME,
|
|
32
32
|
DurableWorkflow: () => DurableWorkflow,
|
|
33
|
+
EVENT_BUFFER_SCAN_LIMIT: () => EVENT_BUFFER_SCAN_LIMIT,
|
|
34
|
+
EVENT_TOKEN_PREFIX: () => EVENT_TOKEN_PREFIX,
|
|
33
35
|
Entities: () => Entities,
|
|
34
36
|
EventAccumulators: () => EventAccumulators,
|
|
35
37
|
FatalError: () => FatalError,
|
|
@@ -68,6 +70,7 @@ __export(index_exports, {
|
|
|
68
70
|
eventPrefix: () => eventPrefix,
|
|
69
71
|
eventToken: () => eventToken,
|
|
70
72
|
indexWaitersByRun: () => indexWaitersByRun,
|
|
73
|
+
isEventToken: () => isEventToken,
|
|
71
74
|
isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
|
|
72
75
|
matchesAttributes: () => matchesAttributes,
|
|
73
76
|
normalizeAttributeRows: () => normalizeAttributeRows,
|
|
@@ -871,10 +874,16 @@ var EventAccumulators = class {
|
|
|
871
874
|
// src/events.ts
|
|
872
875
|
var enc = /* @__PURE__ */ __name((s) => Buffer.from(s, "utf8").toString("base64"), "enc");
|
|
873
876
|
var dec = /* @__PURE__ */ __name((s) => Buffer.from(s, "base64").toString("utf8"), "dec");
|
|
877
|
+
var EVENT_BUFFER_SCAN_LIMIT = 50;
|
|
878
|
+
var EVENT_TOKEN_PREFIX = "event:";
|
|
874
879
|
function eventPrefix(name) {
|
|
875
|
-
return
|
|
880
|
+
return `${EVENT_TOKEN_PREFIX}${enc(name)}:`;
|
|
876
881
|
}
|
|
877
882
|
__name(eventPrefix, "eventPrefix");
|
|
883
|
+
function isEventToken(token) {
|
|
884
|
+
return token.startsWith(EVENT_TOKEN_PREFIX);
|
|
885
|
+
}
|
|
886
|
+
__name(isEventToken, "isEventToken");
|
|
878
887
|
function eventToken(name, match, runId, seq) {
|
|
879
888
|
return `${eventPrefix(name)}${enc(JSON.stringify(match ?? {}))}:${runId}#${seq}`;
|
|
880
889
|
}
|
|
@@ -953,6 +962,47 @@ var RemoteWorkflowExecutor = class {
|
|
|
953
962
|
}
|
|
954
963
|
};
|
|
955
964
|
|
|
965
|
+
// src/run-waiting.ts
|
|
966
|
+
var WEBHOOK_PREFIX = "wh:";
|
|
967
|
+
var CHILD_PREFIX = "child:";
|
|
968
|
+
var BREAKPOINT_PREFIX = "bp:";
|
|
969
|
+
var EVENT_PREFIX = EVENT_TOKEN_PREFIX;
|
|
970
|
+
function classifyWaiterToken(token) {
|
|
971
|
+
if (token.startsWith(WEBHOOK_PREFIX)) return {
|
|
972
|
+
on: "webhook",
|
|
973
|
+
name: token
|
|
974
|
+
};
|
|
975
|
+
if (token.startsWith(BREAKPOINT_PREFIX)) return {
|
|
976
|
+
on: "breakpoint",
|
|
977
|
+
name: "breakpoint"
|
|
978
|
+
};
|
|
979
|
+
if (token.startsWith(CHILD_PREFIX)) return {
|
|
980
|
+
on: "child",
|
|
981
|
+
name: token.slice(CHILD_PREFIX.length)
|
|
982
|
+
};
|
|
983
|
+
if (token.startsWith(EVENT_PREFIX)) return {
|
|
984
|
+
on: "signal",
|
|
985
|
+
name: eventNameOf(token)
|
|
986
|
+
};
|
|
987
|
+
return {
|
|
988
|
+
on: "signal",
|
|
989
|
+
name: token
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
__name(classifyWaiterToken, "classifyWaiterToken");
|
|
993
|
+
function resolveRunWaiting(run, waiterByRun) {
|
|
994
|
+
if (run.status !== "suspended") return void 0;
|
|
995
|
+
const waiter = waiterByRun.get(run.id);
|
|
996
|
+
return waiter ? classifyWaiterToken(waiter.token) : void 0;
|
|
997
|
+
}
|
|
998
|
+
__name(resolveRunWaiting, "resolveRunWaiting");
|
|
999
|
+
function indexWaitersByRun(waiters) {
|
|
1000
|
+
const byRun = /* @__PURE__ */ new Map();
|
|
1001
|
+
for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
|
|
1002
|
+
return byRun;
|
|
1003
|
+
}
|
|
1004
|
+
__name(indexWaitersByRun, "indexWaitersByRun");
|
|
1005
|
+
|
|
956
1006
|
// src/singleton-gate.ts
|
|
957
1007
|
var SINGLETON_RETRY_MS = 1e3;
|
|
958
1008
|
var SINGLETON_RETRY_JITTER_MS = 250;
|
|
@@ -1382,6 +1432,29 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1382
1432
|
value: buffered.payload
|
|
1383
1433
|
};
|
|
1384
1434
|
}, "consumeBuffered");
|
|
1435
|
+
const consumeBufferedEvent = /* @__PURE__ */ __name(async (name, match, token, seq) => {
|
|
1436
|
+
const candidates = await store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
|
|
1437
|
+
for (const candidate of candidates) {
|
|
1438
|
+
if (!eventMatches(candidate.payload, match ?? {})) continue;
|
|
1439
|
+
if (!await store.removeBufferedEvent(candidate.id)) continue;
|
|
1440
|
+
await store.removeSignalWaiter({
|
|
1441
|
+
token,
|
|
1442
|
+
runId,
|
|
1443
|
+
seq
|
|
1444
|
+
});
|
|
1445
|
+
await writeCheckpoint(instantCheckpoint({
|
|
1446
|
+
runId,
|
|
1447
|
+
seq,
|
|
1448
|
+
name: `signal:${token}`,
|
|
1449
|
+
kind: "signal",
|
|
1450
|
+
output: candidate.payload
|
|
1451
|
+
}));
|
|
1452
|
+
return {
|
|
1453
|
+
value: candidate.payload
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
return null;
|
|
1457
|
+
}, "consumeBufferedEvent");
|
|
1385
1458
|
const stampDeadline = /* @__PURE__ */ __name(async (deadlineSeq, label, timeoutMs) => {
|
|
1386
1459
|
const recorded = await readCheckpoint(deadlineSeq);
|
|
1387
1460
|
const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
|
|
@@ -1408,6 +1481,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1408
1481
|
runId,
|
|
1409
1482
|
seq: current
|
|
1410
1483
|
});
|
|
1484
|
+
const lateBuffered2 = await consumeBuffered(token, current);
|
|
1485
|
+
if (lateBuffered2) {
|
|
1486
|
+
await store.removeSignalWaiter({
|
|
1487
|
+
token,
|
|
1488
|
+
runId,
|
|
1489
|
+
seq: current
|
|
1490
|
+
});
|
|
1491
|
+
return lateBuffered2.value;
|
|
1492
|
+
}
|
|
1411
1493
|
throw new WorkflowSuspended();
|
|
1412
1494
|
}
|
|
1413
1495
|
const timeoutMs = opts.timeoutMs;
|
|
@@ -1419,7 +1501,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1419
1501
|
const buffered = await consumeBuffered(token, waitSeq);
|
|
1420
1502
|
if (buffered) return buffered.value;
|
|
1421
1503
|
if (host.clock() >= deadline) {
|
|
1422
|
-
await store.
|
|
1504
|
+
await store.removeSignalWaiter({
|
|
1505
|
+
token,
|
|
1506
|
+
runId,
|
|
1507
|
+
seq: waitSeq
|
|
1508
|
+
}).catch(() => void 0);
|
|
1423
1509
|
throw new SignalTimeoutError(token, timeoutMs);
|
|
1424
1510
|
}
|
|
1425
1511
|
await store.putSignalWaiter({
|
|
@@ -1427,6 +1513,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1427
1513
|
runId,
|
|
1428
1514
|
seq: waitSeq
|
|
1429
1515
|
});
|
|
1516
|
+
const lateBuffered = await consumeBuffered(token, waitSeq);
|
|
1517
|
+
if (lateBuffered) {
|
|
1518
|
+
await store.removeSignalWaiter({
|
|
1519
|
+
token,
|
|
1520
|
+
runId,
|
|
1521
|
+
seq: waitSeq
|
|
1522
|
+
});
|
|
1523
|
+
return lateBuffered.value;
|
|
1524
|
+
}
|
|
1430
1525
|
throw new WorkflowSuspended(deadline);
|
|
1431
1526
|
}, "waitForSignal");
|
|
1432
1527
|
const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
|
|
@@ -1440,6 +1535,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1440
1535
|
runId,
|
|
1441
1536
|
seq: current
|
|
1442
1537
|
});
|
|
1538
|
+
const bufferedHit2 = await consumeBufferedEvent(name, opts?.match, token2, current);
|
|
1539
|
+
if (bufferedHit2) return bufferedHit2.value;
|
|
1443
1540
|
throw new WorkflowSuspended();
|
|
1444
1541
|
}
|
|
1445
1542
|
const timeoutMs = opts.timeoutMs;
|
|
@@ -1450,7 +1547,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1450
1547
|
const waited = await readCheckpoint(waitSeq);
|
|
1451
1548
|
if (waited && waited.status === "completed") return waited.output;
|
|
1452
1549
|
if (host.clock() >= deadline) {
|
|
1453
|
-
await store.
|
|
1550
|
+
await store.removeSignalWaiter({
|
|
1551
|
+
token,
|
|
1552
|
+
runId,
|
|
1553
|
+
seq: waitSeq
|
|
1554
|
+
}).catch(() => void 0);
|
|
1454
1555
|
throw new SignalTimeoutError(`event:${name}`, timeoutMs);
|
|
1455
1556
|
}
|
|
1456
1557
|
await store.putSignalWaiter({
|
|
@@ -1458,6 +1559,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1458
1559
|
runId,
|
|
1459
1560
|
seq: waitSeq
|
|
1460
1561
|
});
|
|
1562
|
+
const bufferedHit = await consumeBufferedEvent(name, opts.match, token, waitSeq);
|
|
1563
|
+
if (bufferedHit) return bufferedHit.value;
|
|
1461
1564
|
throw new WorkflowSuspended(deadline);
|
|
1462
1565
|
}, "waitForEvent");
|
|
1463
1566
|
const task = /* @__PURE__ */ __name(async (name, dispatch, options) => {
|
|
@@ -1851,6 +1954,7 @@ var WorkflowEngine = class {
|
|
|
1851
1954
|
maxRecoveryAttempts;
|
|
1852
1955
|
remoteAdvanceSilenceMs;
|
|
1853
1956
|
reconcileMs;
|
|
1957
|
+
eventBufferTtlMs;
|
|
1854
1958
|
remoteRedispatchMs;
|
|
1855
1959
|
remoteRedispatchMax;
|
|
1856
1960
|
webhookUrl;
|
|
@@ -1924,6 +2028,7 @@ var WorkflowEngine = class {
|
|
|
1924
2028
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
1925
2029
|
this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
|
|
1926
2030
|
this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
|
|
2031
|
+
this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
|
|
1927
2032
|
this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
|
|
1928
2033
|
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
1929
2034
|
this.webhookUrl = deps.webhookUrl;
|
|
@@ -2596,9 +2701,81 @@ var WorkflowEngine = class {
|
|
|
2596
2701
|
/**
|
|
2597
2702
|
* Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
|
|
2598
2703
|
* boot. A run still not due re-suspends cheaply without running new work.
|
|
2704
|
+
*
|
|
2705
|
+
* Piggy-backs the signal/event-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a
|
|
2706
|
+
* crash between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
|
|
2707
|
+
* `takeBufferedSignal`/`listBufferedEvents`, or the other side's `takeSignalWaiter`/fan-out →
|
|
2708
|
+
* `bufferSignal`/`bufferEvent` → re-check — see `signal`'s interleaving proof, which `publishEvent`
|
|
2709
|
+
* mirrors for events) can leave both rows stranded with nothing left to pair them: the waiter's turn
|
|
2710
|
+
* already suspended, the other side that buffered it already returned. Event-wait suspends already
|
|
2711
|
+
* carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so they show up
|
|
2712
|
+
* HERE — that's what makes this due-timer pass the natural place for the sweep (and, for events, also
|
|
2713
|
+
* where an `eventBufferTtlMs` prune piggy-backs — see {@link reconcileBufferedEvent}). One bulk waiter
|
|
2714
|
+
* scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
|
|
2599
2715
|
*/
|
|
2600
2716
|
async resumeDueTimers(nowMs = this.clock()) {
|
|
2601
|
-
|
|
2717
|
+
const due = await this.store.listDueTimers(nowMs, this.namespace);
|
|
2718
|
+
if (due.length === 0) return [];
|
|
2719
|
+
const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
|
|
2720
|
+
return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
|
|
2724
|
+
* a registered signal OR event waiter and a matching payload is already buffered, pair and deliver
|
|
2725
|
+
* them directly instead of falling through to a full replay. Child/breakpoint waiter tokens are
|
|
2726
|
+
* never buffered (a child/breakpoint notifies via `signal` only once its counterpart is already
|
|
2727
|
+
* registered) — `takeBufferedSignal` on those is a guaranteed miss, so this only ever does real work
|
|
2728
|
+
* for a plain signal or an event waiter (classified via {@link isEventToken}).
|
|
2729
|
+
*
|
|
2730
|
+
* Also opportunistically prunes expired buffered EVENTS for this waiter's name when
|
|
2731
|
+
* {@link eventBufferTtlMs} is set (see its doc — scoped to names a registered waiter references in
|
|
2732
|
+
* this tick's batch, piggy-backing the SAME scan rather than a separate pass), even on a miss.
|
|
2733
|
+
*
|
|
2734
|
+
* Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
|
|
2735
|
+
* no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
|
|
2736
|
+
* via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
|
|
2737
|
+
* result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
|
|
2738
|
+
* trigger a second, redundant `resume` call on the same run.
|
|
2739
|
+
*/
|
|
2740
|
+
async reconcileSignalWaiter(run, waiter) {
|
|
2741
|
+
if (!waiter) return void 0;
|
|
2742
|
+
if (isEventToken(waiter.token)) return this.reconcileBufferedEvent(run, waiter);
|
|
2743
|
+
const buffered = await this.store.takeBufferedSignal(waiter.token);
|
|
2744
|
+
if (!buffered) return void 0;
|
|
2745
|
+
await this.store.removeSignalWaiter(waiter);
|
|
2746
|
+
const settled = await this.deliverSignal(waiter, buffered.payload);
|
|
2747
|
+
return settled ?? {
|
|
2748
|
+
runId: run.id,
|
|
2749
|
+
status: run.status
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* The EVENT half of {@link reconcileSignalWaiter}: scan this waiter's name for a matching buffered
|
|
2754
|
+
* event (same list+evaluate+claim shape as `waitForEvent`'s own consumption — the match predicate
|
|
2755
|
+
* belongs to the waiter, never the store), pruning any expired candidates it encounters along the
|
|
2756
|
+
* way when {@link eventBufferTtlMs} is configured. A pruned-but-non-matching candidate does not stop
|
|
2757
|
+
* the scan; only a MATCHING hit (or exhausting the scan window) does.
|
|
2758
|
+
*/
|
|
2759
|
+
async reconcileBufferedEvent(run, waiter) {
|
|
2760
|
+
const name = eventNameOf(waiter.token);
|
|
2761
|
+
const match = eventMatchOf(waiter.token);
|
|
2762
|
+
const candidates = await this.store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
|
|
2763
|
+
const nowMs = this.clock();
|
|
2764
|
+
for (const candidate of candidates) {
|
|
2765
|
+
if (this.eventBufferTtlMs != null && nowMs - candidate.publishedAt > this.eventBufferTtlMs) {
|
|
2766
|
+
await this.store.removeBufferedEvent(candidate.id);
|
|
2767
|
+
continue;
|
|
2768
|
+
}
|
|
2769
|
+
if (!eventMatches(candidate.payload, match)) continue;
|
|
2770
|
+
if (!await this.store.removeBufferedEvent(candidate.id)) continue;
|
|
2771
|
+
await this.store.removeSignalWaiter(waiter);
|
|
2772
|
+
const settled = await this.deliverSignal(waiter, candidate.payload);
|
|
2773
|
+
return settled ?? {
|
|
2774
|
+
runId: run.id,
|
|
2775
|
+
status: run.status
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
return void 0;
|
|
2602
2779
|
}
|
|
2603
2780
|
/**
|
|
2604
2781
|
* Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
|
|
@@ -2788,6 +2965,21 @@ var WorkflowEngine = class {
|
|
|
2788
2965
|
* 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
|
|
2789
2966
|
* payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
|
|
2790
2967
|
* of the same logical event (default: a fresh uuid, so each publish triggers once).
|
|
2968
|
+
*
|
|
2969
|
+
* Semantics (deliberate — mirrors `signalWithStart`'s reliability contract, so document it exactly):
|
|
2970
|
+
* a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator / starts ≥1
|
|
2971
|
+
* subscriber, behaves exactly as above and is NOT buffered — fan-out stays live-only, same as
|
|
2972
|
+
* today. A publish that touches NEITHER — no live waiter matched, no subscriber exists/started —
|
|
2973
|
+
* buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
|
|
2974
|
+
* silently dropped just because nobody happened to be listening yet. That buffered copy is consumed
|
|
2975
|
+
* by the FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload —
|
|
2976
|
+
* point-to-point on redelivery, by design (only one waiter ever gets it, even though the LIVE path
|
|
2977
|
+
* above is fan-out) — never by a second, later-registered onEvent subscriber (subscriber starts stay
|
|
2978
|
+
* live-only; `opts.id` dedupe applies to those only, unchanged). Right after buffering, this re-checks
|
|
2979
|
+
* `listSignalWaiters` ONCE — sandwich parity with `signal`'s own take → buffer → re-check — so a
|
|
2980
|
+
* waiter that registers in the sliver between the initial miss and the buffer write is still paired
|
|
2981
|
+
* instead of leaving both rows stranded (see `signal`'s interleaving proof; `waitForEvent`'s own
|
|
2982
|
+
* post-registration buffer scan is the mirror-image half of this same race).
|
|
2791
2983
|
*/
|
|
2792
2984
|
async publishEvent(name, payload, opts) {
|
|
2793
2985
|
let touched = 0;
|
|
@@ -2814,18 +3006,36 @@ var WorkflowEngine = class {
|
|
|
2814
3006
|
}
|
|
2815
3007
|
}
|
|
2816
3008
|
}
|
|
3009
|
+
if (touched > 0 || opts?.buffer === false) return touched;
|
|
3010
|
+
const bufferedId = globalThis.crypto.randomUUID();
|
|
3011
|
+
await this.store.bufferEvent({
|
|
3012
|
+
name,
|
|
3013
|
+
payload,
|
|
3014
|
+
id: bufferedId,
|
|
3015
|
+
publishedAt: this.clock()
|
|
3016
|
+
});
|
|
3017
|
+
const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
|
|
3018
|
+
const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
|
|
3019
|
+
if (!lateWaiter) return touched;
|
|
3020
|
+
if (!await this.store.removeBufferedEvent(bufferedId)) return touched;
|
|
3021
|
+
const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
|
|
3022
|
+
if (waiter) {
|
|
3023
|
+
const settled = await this.deliverSignal(waiter, payload);
|
|
3024
|
+
if (settled) touched += 1;
|
|
3025
|
+
}
|
|
2817
3026
|
return touched;
|
|
2818
3027
|
}
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
3028
|
+
/**
|
|
3029
|
+
* Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
|
|
3030
|
+
* resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
|
|
3031
|
+
* due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
|
|
3032
|
+
* same way.
|
|
3033
|
+
*/
|
|
3034
|
+
async deliverSignal(waiter, payload) {
|
|
2825
3035
|
await this.store.saveCheckpoint(instantCheckpoint({
|
|
2826
3036
|
runId: waiter.runId,
|
|
2827
3037
|
seq: waiter.seq,
|
|
2828
|
-
name: `signal:${token}`,
|
|
3038
|
+
name: `signal:${waiter.token}`,
|
|
2829
3039
|
kind: "signal",
|
|
2830
3040
|
output: payload,
|
|
2831
3041
|
// Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
|
|
@@ -2840,6 +3050,42 @@ var WorkflowEngine = class {
|
|
|
2840
3050
|
});
|
|
2841
3051
|
}
|
|
2842
3052
|
/**
|
|
3053
|
+
* Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
|
|
3054
|
+
* the run result — or null if no run is (or ends up) waiting for it.
|
|
3055
|
+
*
|
|
3056
|
+
* Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
|
|
3057
|
+
* the remote `waitSignal` command): a waiter can register in the sliver between this method's
|
|
3058
|
+
* initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
|
|
3059
|
+
* return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
|
|
3060
|
+
* nothing left to pair them — a lost wake, forever suspended.
|
|
3061
|
+
*
|
|
3062
|
+
* The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
|
|
3063
|
+
* → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
|
|
3064
|
+
* write, because it always follows that write in real time:
|
|
3065
|
+
* - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
|
|
3066
|
+
* (the pre-existing, non-racy path).
|
|
3067
|
+
* - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
|
|
3068
|
+
* it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
|
|
3069
|
+
* buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
|
|
3070
|
+
* re-check strictly follows its own put).
|
|
3071
|
+
* - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
|
|
3072
|
+
* catches it.
|
|
3073
|
+
* Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
|
|
3074
|
+
* side actually removes the row is the one that delivers, so a signal is never delivered twice.
|
|
3075
|
+
*/
|
|
3076
|
+
async signal(token, payload) {
|
|
3077
|
+
const waiter = await this.store.takeSignalWaiter(token);
|
|
3078
|
+
if (waiter) return this.deliverSignal(waiter, payload);
|
|
3079
|
+
await this.store.bufferSignal(token, payload);
|
|
3080
|
+
const lateWaiter = await this.store.takeSignalWaiter(token);
|
|
3081
|
+
if (!lateWaiter) return null;
|
|
3082
|
+
const reclaimed = await this.store.takeBufferedSignal(token);
|
|
3083
|
+
if (!reclaimed) {
|
|
3084
|
+
return null;
|
|
3085
|
+
}
|
|
3086
|
+
return this.deliverSignal(lateWaiter, reclaimed.payload);
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
2843
3089
|
* Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
|
|
2844
3090
|
* signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
|
|
2845
3091
|
* consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
|
|
@@ -3312,6 +3558,14 @@ var WorkflowEngine = class {
|
|
|
3312
3558
|
error: outcome.error
|
|
3313
3559
|
};
|
|
3314
3560
|
}
|
|
3561
|
+
const latest = await this.store.getRun(run.id);
|
|
3562
|
+
if (latest?.status === "cancelled") {
|
|
3563
|
+
return {
|
|
3564
|
+
runId: run.id,
|
|
3565
|
+
status: "cancelled",
|
|
3566
|
+
error: latest.error
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3315
3569
|
await this.store.updateRun(run.id, {
|
|
3316
3570
|
status: "suspended",
|
|
3317
3571
|
wakeAt: this.reconcileWakeAt(outcome.wakeAt),
|
|
@@ -3654,22 +3908,34 @@ var WorkflowEngine = class {
|
|
|
3654
3908
|
}));
|
|
3655
3909
|
wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
|
|
3656
3910
|
} else if (cmd.kind === "waitSignal") {
|
|
3657
|
-
const
|
|
3658
|
-
if (buffered) {
|
|
3911
|
+
const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
|
|
3659
3912
|
await this.store.saveCheckpoint(instantCheckpoint({
|
|
3660
3913
|
runId: run.id,
|
|
3661
3914
|
seq: cmd.seq,
|
|
3662
3915
|
name: `signal:${cmd.signal}`,
|
|
3663
3916
|
kind: "signal",
|
|
3664
|
-
output:
|
|
3917
|
+
output: payload
|
|
3665
3918
|
}));
|
|
3666
3919
|
setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
|
|
3920
|
+
}, "deliverBuffered");
|
|
3921
|
+
const buffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
3922
|
+
if (buffered) {
|
|
3923
|
+
await deliverBuffered(buffered.payload);
|
|
3667
3924
|
} else {
|
|
3668
3925
|
await this.store.putSignalWaiter({
|
|
3669
3926
|
token: cmd.signal,
|
|
3670
3927
|
runId: run.id,
|
|
3671
3928
|
seq: cmd.seq
|
|
3672
3929
|
});
|
|
3930
|
+
const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
3931
|
+
if (lateBuffered) {
|
|
3932
|
+
await this.store.removeSignalWaiter({
|
|
3933
|
+
token: cmd.signal,
|
|
3934
|
+
runId: run.id,
|
|
3935
|
+
seq: cmd.seq
|
|
3936
|
+
});
|
|
3937
|
+
await deliverBuffered(lateBuffered.payload);
|
|
3938
|
+
}
|
|
3673
3939
|
}
|
|
3674
3940
|
} else if (cmd.kind === "startChild") {
|
|
3675
3941
|
const childId = `${run.id}.child.${cmd.seq}`;
|
|
@@ -4529,47 +4795,6 @@ var TERMINAL_RUN_STATUSES = [
|
|
|
4529
4795
|
"dead"
|
|
4530
4796
|
];
|
|
4531
4797
|
|
|
4532
|
-
// src/run-waiting.ts
|
|
4533
|
-
var WEBHOOK_PREFIX = "wh:";
|
|
4534
|
-
var CHILD_PREFIX = "child:";
|
|
4535
|
-
var BREAKPOINT_PREFIX = "bp:";
|
|
4536
|
-
var EVENT_PREFIX = "event:";
|
|
4537
|
-
function classifyWaiterToken(token) {
|
|
4538
|
-
if (token.startsWith(WEBHOOK_PREFIX)) return {
|
|
4539
|
-
on: "webhook",
|
|
4540
|
-
name: token
|
|
4541
|
-
};
|
|
4542
|
-
if (token.startsWith(BREAKPOINT_PREFIX)) return {
|
|
4543
|
-
on: "breakpoint",
|
|
4544
|
-
name: "breakpoint"
|
|
4545
|
-
};
|
|
4546
|
-
if (token.startsWith(CHILD_PREFIX)) return {
|
|
4547
|
-
on: "child",
|
|
4548
|
-
name: token.slice(CHILD_PREFIX.length)
|
|
4549
|
-
};
|
|
4550
|
-
if (token.startsWith(EVENT_PREFIX)) return {
|
|
4551
|
-
on: "signal",
|
|
4552
|
-
name: eventNameOf(token)
|
|
4553
|
-
};
|
|
4554
|
-
return {
|
|
4555
|
-
on: "signal",
|
|
4556
|
-
name: token
|
|
4557
|
-
};
|
|
4558
|
-
}
|
|
4559
|
-
__name(classifyWaiterToken, "classifyWaiterToken");
|
|
4560
|
-
function resolveRunWaiting(run, waiterByRun) {
|
|
4561
|
-
if (run.status !== "suspended") return void 0;
|
|
4562
|
-
const waiter = waiterByRun.get(run.id);
|
|
4563
|
-
return waiter ? classifyWaiterToken(waiter.token) : void 0;
|
|
4564
|
-
}
|
|
4565
|
-
__name(resolveRunWaiting, "resolveRunWaiting");
|
|
4566
|
-
function indexWaitersByRun(waiters) {
|
|
4567
|
-
const byRun = /* @__PURE__ */ new Map();
|
|
4568
|
-
for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
|
|
4569
|
-
return byRun;
|
|
4570
|
-
}
|
|
4571
|
-
__name(indexWaitersByRun, "indexWaitersByRun");
|
|
4572
|
-
|
|
4573
4798
|
// src/codec-state-store.ts
|
|
4574
4799
|
var CodecStateStore = class {
|
|
4575
4800
|
static {
|
|
@@ -4677,6 +4902,9 @@ var CodecStateStore = class {
|
|
|
4677
4902
|
listSignalWaiters(prefix) {
|
|
4678
4903
|
return this.inner.listSignalWaiters(prefix);
|
|
4679
4904
|
}
|
|
4905
|
+
removeSignalWaiter(waiter) {
|
|
4906
|
+
return this.inner.removeSignalWaiter(waiter);
|
|
4907
|
+
}
|
|
4680
4908
|
bufferSignal(token, payload) {
|
|
4681
4909
|
return this.inner.bufferSignal(token, this.enc(payload));
|
|
4682
4910
|
}
|
|
@@ -4686,6 +4914,22 @@ var CodecStateStore = class {
|
|
|
4686
4914
|
payload: this.dec(buffered.payload)
|
|
4687
4915
|
};
|
|
4688
4916
|
}
|
|
4917
|
+
bufferEvent(input) {
|
|
4918
|
+
return this.inner.bufferEvent({
|
|
4919
|
+
...input,
|
|
4920
|
+
payload: this.enc(input.payload)
|
|
4921
|
+
});
|
|
4922
|
+
}
|
|
4923
|
+
async listBufferedEvents(name, limit) {
|
|
4924
|
+
const events = await this.inner.listBufferedEvents(name, limit);
|
|
4925
|
+
return events.map((e) => ({
|
|
4926
|
+
...e,
|
|
4927
|
+
payload: this.dec(e.payload)
|
|
4928
|
+
}));
|
|
4929
|
+
}
|
|
4930
|
+
removeBufferedEvent(id) {
|
|
4931
|
+
return this.inner.removeBufferedEvent(id);
|
|
4932
|
+
}
|
|
4689
4933
|
async listRuns(query) {
|
|
4690
4934
|
return (await this.inner.listRuns(query)).map((r) => this.decRun(r));
|
|
4691
4935
|
}
|
|
@@ -5129,6 +5373,12 @@ var InMemoryStateStore = class {
|
|
|
5129
5373
|
...w
|
|
5130
5374
|
}));
|
|
5131
5375
|
}
|
|
5376
|
+
async removeSignalWaiter(waiter) {
|
|
5377
|
+
const current = this.signalWaiters.get(waiter.token);
|
|
5378
|
+
if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
|
|
5379
|
+
this.signalWaiters.delete(waiter.token);
|
|
5380
|
+
}
|
|
5381
|
+
}
|
|
5132
5382
|
// No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
|
|
5133
5383
|
// across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
|
|
5134
5384
|
async transaction(work) {
|
|
@@ -5152,6 +5402,28 @@ var InMemoryStateStore = class {
|
|
|
5152
5402
|
payload
|
|
5153
5403
|
};
|
|
5154
5404
|
}
|
|
5405
|
+
/** `id -> row`, so {@link removeBufferedEvent} can delete an exact row regardless of iteration
|
|
5406
|
+
* order — mirrors the SQL stores' PK-by-id delete. */
|
|
5407
|
+
bufferedEvents = /* @__PURE__ */ new Map();
|
|
5408
|
+
async bufferEvent(input) {
|
|
5409
|
+
this.bufferedEvents.set(input.id, {
|
|
5410
|
+
name: input.name,
|
|
5411
|
+
payload: input.payload,
|
|
5412
|
+
publishedAt: input.publishedAt
|
|
5413
|
+
});
|
|
5414
|
+
}
|
|
5415
|
+
async listBufferedEvents(name, limit) {
|
|
5416
|
+
return [
|
|
5417
|
+
...this.bufferedEvents.entries()
|
|
5418
|
+
].filter(([, e]) => e.name === name).sort((a, b) => a[1].publishedAt - b[1].publishedAt).slice(0, limit).map(([id, e]) => ({
|
|
5419
|
+
id,
|
|
5420
|
+
payload: e.payload,
|
|
5421
|
+
publishedAt: e.publishedAt
|
|
5422
|
+
}));
|
|
5423
|
+
}
|
|
5424
|
+
async removeBufferedEvent(id) {
|
|
5425
|
+
return this.bufferedEvents.delete(id);
|
|
5426
|
+
}
|
|
5155
5427
|
/**
|
|
5156
5428
|
* Candidate run ids satisfying ONE attribute predicate, served from {@link attributeIndex} — the
|
|
5157
5429
|
* pushdown step. Numeric predicates scan only `numValue` rows under the key; string/boolean ones
|
|
@@ -5311,6 +5583,8 @@ var InMemoryTransport = class {
|
|
|
5311
5583
|
DURABLE_STEP_CONFIG,
|
|
5312
5584
|
DURABLE_STEP_NAME,
|
|
5313
5585
|
DurableWorkflow,
|
|
5586
|
+
EVENT_BUFFER_SCAN_LIMIT,
|
|
5587
|
+
EVENT_TOKEN_PREFIX,
|
|
5314
5588
|
Entities,
|
|
5315
5589
|
EventAccumulators,
|
|
5316
5590
|
FatalError,
|
|
@@ -5349,6 +5623,7 @@ var InMemoryTransport = class {
|
|
|
5349
5623
|
eventPrefix,
|
|
5350
5624
|
eventToken,
|
|
5351
5625
|
indexWaitersByRun,
|
|
5626
|
+
isEventToken,
|
|
5352
5627
|
isWorkflowControlFlowSignal,
|
|
5353
5628
|
matchesAttributes,
|
|
5354
5629
|
normalizeAttributeRows,
|