@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.js
CHANGED
|
@@ -783,10 +783,16 @@ var EventAccumulators = class {
|
|
|
783
783
|
// src/events.ts
|
|
784
784
|
var enc = /* @__PURE__ */ __name((s) => Buffer.from(s, "utf8").toString("base64"), "enc");
|
|
785
785
|
var dec = /* @__PURE__ */ __name((s) => Buffer.from(s, "base64").toString("utf8"), "dec");
|
|
786
|
+
var EVENT_BUFFER_SCAN_LIMIT = 50;
|
|
787
|
+
var EVENT_TOKEN_PREFIX = "event:";
|
|
786
788
|
function eventPrefix(name) {
|
|
787
|
-
return
|
|
789
|
+
return `${EVENT_TOKEN_PREFIX}${enc(name)}:`;
|
|
788
790
|
}
|
|
789
791
|
__name(eventPrefix, "eventPrefix");
|
|
792
|
+
function isEventToken(token) {
|
|
793
|
+
return token.startsWith(EVENT_TOKEN_PREFIX);
|
|
794
|
+
}
|
|
795
|
+
__name(isEventToken, "isEventToken");
|
|
790
796
|
function eventToken(name, match, runId, seq) {
|
|
791
797
|
return `${eventPrefix(name)}${enc(JSON.stringify(match ?? {}))}:${runId}#${seq}`;
|
|
792
798
|
}
|
|
@@ -865,6 +871,47 @@ var RemoteWorkflowExecutor = class {
|
|
|
865
871
|
}
|
|
866
872
|
};
|
|
867
873
|
|
|
874
|
+
// src/run-waiting.ts
|
|
875
|
+
var WEBHOOK_PREFIX = "wh:";
|
|
876
|
+
var CHILD_PREFIX = "child:";
|
|
877
|
+
var BREAKPOINT_PREFIX = "bp:";
|
|
878
|
+
var EVENT_PREFIX = EVENT_TOKEN_PREFIX;
|
|
879
|
+
function classifyWaiterToken(token) {
|
|
880
|
+
if (token.startsWith(WEBHOOK_PREFIX)) return {
|
|
881
|
+
on: "webhook",
|
|
882
|
+
name: token
|
|
883
|
+
};
|
|
884
|
+
if (token.startsWith(BREAKPOINT_PREFIX)) return {
|
|
885
|
+
on: "breakpoint",
|
|
886
|
+
name: "breakpoint"
|
|
887
|
+
};
|
|
888
|
+
if (token.startsWith(CHILD_PREFIX)) return {
|
|
889
|
+
on: "child",
|
|
890
|
+
name: token.slice(CHILD_PREFIX.length)
|
|
891
|
+
};
|
|
892
|
+
if (token.startsWith(EVENT_PREFIX)) return {
|
|
893
|
+
on: "signal",
|
|
894
|
+
name: eventNameOf(token)
|
|
895
|
+
};
|
|
896
|
+
return {
|
|
897
|
+
on: "signal",
|
|
898
|
+
name: token
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
__name(classifyWaiterToken, "classifyWaiterToken");
|
|
902
|
+
function resolveRunWaiting(run, waiterByRun) {
|
|
903
|
+
if (run.status !== "suspended") return void 0;
|
|
904
|
+
const waiter = waiterByRun.get(run.id);
|
|
905
|
+
return waiter ? classifyWaiterToken(waiter.token) : void 0;
|
|
906
|
+
}
|
|
907
|
+
__name(resolveRunWaiting, "resolveRunWaiting");
|
|
908
|
+
function indexWaitersByRun(waiters) {
|
|
909
|
+
const byRun = /* @__PURE__ */ new Map();
|
|
910
|
+
for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
|
|
911
|
+
return byRun;
|
|
912
|
+
}
|
|
913
|
+
__name(indexWaitersByRun, "indexWaitersByRun");
|
|
914
|
+
|
|
868
915
|
// src/singleton-gate.ts
|
|
869
916
|
var SINGLETON_RETRY_MS = 1e3;
|
|
870
917
|
var SINGLETON_RETRY_JITTER_MS = 250;
|
|
@@ -1294,6 +1341,29 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1294
1341
|
value: buffered.payload
|
|
1295
1342
|
};
|
|
1296
1343
|
}, "consumeBuffered");
|
|
1344
|
+
const consumeBufferedEvent = /* @__PURE__ */ __name(async (name, match, token, seq) => {
|
|
1345
|
+
const candidates = await store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
|
|
1346
|
+
for (const candidate of candidates) {
|
|
1347
|
+
if (!eventMatches(candidate.payload, match ?? {})) continue;
|
|
1348
|
+
if (!await store.removeBufferedEvent(candidate.id)) continue;
|
|
1349
|
+
await store.removeSignalWaiter({
|
|
1350
|
+
token,
|
|
1351
|
+
runId,
|
|
1352
|
+
seq
|
|
1353
|
+
});
|
|
1354
|
+
await writeCheckpoint(instantCheckpoint({
|
|
1355
|
+
runId,
|
|
1356
|
+
seq,
|
|
1357
|
+
name: `signal:${token}`,
|
|
1358
|
+
kind: "signal",
|
|
1359
|
+
output: candidate.payload
|
|
1360
|
+
}));
|
|
1361
|
+
return {
|
|
1362
|
+
value: candidate.payload
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
return null;
|
|
1366
|
+
}, "consumeBufferedEvent");
|
|
1297
1367
|
const stampDeadline = /* @__PURE__ */ __name(async (deadlineSeq, label, timeoutMs) => {
|
|
1298
1368
|
const recorded = await readCheckpoint(deadlineSeq);
|
|
1299
1369
|
const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
|
|
@@ -1320,6 +1390,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1320
1390
|
runId,
|
|
1321
1391
|
seq: current
|
|
1322
1392
|
});
|
|
1393
|
+
const lateBuffered2 = await consumeBuffered(token, current);
|
|
1394
|
+
if (lateBuffered2) {
|
|
1395
|
+
await store.removeSignalWaiter({
|
|
1396
|
+
token,
|
|
1397
|
+
runId,
|
|
1398
|
+
seq: current
|
|
1399
|
+
});
|
|
1400
|
+
return lateBuffered2.value;
|
|
1401
|
+
}
|
|
1323
1402
|
throw new WorkflowSuspended();
|
|
1324
1403
|
}
|
|
1325
1404
|
const timeoutMs = opts.timeoutMs;
|
|
@@ -1331,7 +1410,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1331
1410
|
const buffered = await consumeBuffered(token, waitSeq);
|
|
1332
1411
|
if (buffered) return buffered.value;
|
|
1333
1412
|
if (host.clock() >= deadline) {
|
|
1334
|
-
await store.
|
|
1413
|
+
await store.removeSignalWaiter({
|
|
1414
|
+
token,
|
|
1415
|
+
runId,
|
|
1416
|
+
seq: waitSeq
|
|
1417
|
+
}).catch(() => void 0);
|
|
1335
1418
|
throw new SignalTimeoutError(token, timeoutMs);
|
|
1336
1419
|
}
|
|
1337
1420
|
await store.putSignalWaiter({
|
|
@@ -1339,6 +1422,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1339
1422
|
runId,
|
|
1340
1423
|
seq: waitSeq
|
|
1341
1424
|
});
|
|
1425
|
+
const lateBuffered = await consumeBuffered(token, waitSeq);
|
|
1426
|
+
if (lateBuffered) {
|
|
1427
|
+
await store.removeSignalWaiter({
|
|
1428
|
+
token,
|
|
1429
|
+
runId,
|
|
1430
|
+
seq: waitSeq
|
|
1431
|
+
});
|
|
1432
|
+
return lateBuffered.value;
|
|
1433
|
+
}
|
|
1342
1434
|
throw new WorkflowSuspended(deadline);
|
|
1343
1435
|
}, "waitForSignal");
|
|
1344
1436
|
const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
|
|
@@ -1352,6 +1444,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1352
1444
|
runId,
|
|
1353
1445
|
seq: current
|
|
1354
1446
|
});
|
|
1447
|
+
const bufferedHit2 = await consumeBufferedEvent(name, opts?.match, token2, current);
|
|
1448
|
+
if (bufferedHit2) return bufferedHit2.value;
|
|
1355
1449
|
throw new WorkflowSuspended();
|
|
1356
1450
|
}
|
|
1357
1451
|
const timeoutMs = opts.timeoutMs;
|
|
@@ -1362,7 +1456,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1362
1456
|
const waited = await readCheckpoint(waitSeq);
|
|
1363
1457
|
if (waited && waited.status === "completed") return waited.output;
|
|
1364
1458
|
if (host.clock() >= deadline) {
|
|
1365
|
-
await store.
|
|
1459
|
+
await store.removeSignalWaiter({
|
|
1460
|
+
token,
|
|
1461
|
+
runId,
|
|
1462
|
+
seq: waitSeq
|
|
1463
|
+
}).catch(() => void 0);
|
|
1366
1464
|
throw new SignalTimeoutError(`event:${name}`, timeoutMs);
|
|
1367
1465
|
}
|
|
1368
1466
|
await store.putSignalWaiter({
|
|
@@ -1370,6 +1468,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
1370
1468
|
runId,
|
|
1371
1469
|
seq: waitSeq
|
|
1372
1470
|
});
|
|
1471
|
+
const bufferedHit = await consumeBufferedEvent(name, opts.match, token, waitSeq);
|
|
1472
|
+
if (bufferedHit) return bufferedHit.value;
|
|
1373
1473
|
throw new WorkflowSuspended(deadline);
|
|
1374
1474
|
}, "waitForEvent");
|
|
1375
1475
|
const task = /* @__PURE__ */ __name(async (name, dispatch, options) => {
|
|
@@ -1763,6 +1863,7 @@ var WorkflowEngine = class {
|
|
|
1763
1863
|
maxRecoveryAttempts;
|
|
1764
1864
|
remoteAdvanceSilenceMs;
|
|
1765
1865
|
reconcileMs;
|
|
1866
|
+
eventBufferTtlMs;
|
|
1766
1867
|
remoteRedispatchMs;
|
|
1767
1868
|
remoteRedispatchMax;
|
|
1768
1869
|
webhookUrl;
|
|
@@ -1836,6 +1937,7 @@ var WorkflowEngine = class {
|
|
|
1836
1937
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
1837
1938
|
this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
|
|
1838
1939
|
this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
|
|
1940
|
+
this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
|
|
1839
1941
|
this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
|
|
1840
1942
|
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
1841
1943
|
this.webhookUrl = deps.webhookUrl;
|
|
@@ -2508,9 +2610,81 @@ var WorkflowEngine = class {
|
|
|
2508
2610
|
/**
|
|
2509
2611
|
* Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
|
|
2510
2612
|
* boot. A run still not due re-suspends cheaply without running new work.
|
|
2613
|
+
*
|
|
2614
|
+
* Piggy-backs the signal/event-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a
|
|
2615
|
+
* crash between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
|
|
2616
|
+
* `takeBufferedSignal`/`listBufferedEvents`, or the other side's `takeSignalWaiter`/fan-out →
|
|
2617
|
+
* `bufferSignal`/`bufferEvent` → re-check — see `signal`'s interleaving proof, which `publishEvent`
|
|
2618
|
+
* mirrors for events) can leave both rows stranded with nothing left to pair them: the waiter's turn
|
|
2619
|
+
* already suspended, the other side that buffered it already returned. Event-wait suspends already
|
|
2620
|
+
* carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so they show up
|
|
2621
|
+
* HERE — that's what makes this due-timer pass the natural place for the sweep (and, for events, also
|
|
2622
|
+
* where an `eventBufferTtlMs` prune piggy-backs — see {@link reconcileBufferedEvent}). One bulk waiter
|
|
2623
|
+
* scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
|
|
2511
2624
|
*/
|
|
2512
2625
|
async resumeDueTimers(nowMs = this.clock()) {
|
|
2513
|
-
|
|
2626
|
+
const due = await this.store.listDueTimers(nowMs, this.namespace);
|
|
2627
|
+
if (due.length === 0) return [];
|
|
2628
|
+
const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
|
|
2629
|
+
return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
|
|
2633
|
+
* a registered signal OR event waiter and a matching payload is already buffered, pair and deliver
|
|
2634
|
+
* them directly instead of falling through to a full replay. Child/breakpoint waiter tokens are
|
|
2635
|
+
* never buffered (a child/breakpoint notifies via `signal` only once its counterpart is already
|
|
2636
|
+
* registered) — `takeBufferedSignal` on those is a guaranteed miss, so this only ever does real work
|
|
2637
|
+
* for a plain signal or an event waiter (classified via {@link isEventToken}).
|
|
2638
|
+
*
|
|
2639
|
+
* Also opportunistically prunes expired buffered EVENTS for this waiter's name when
|
|
2640
|
+
* {@link eventBufferTtlMs} is set (see its doc — scoped to names a registered waiter references in
|
|
2641
|
+
* this tick's batch, piggy-backing the SAME scan rather than a separate pass), even on a miss.
|
|
2642
|
+
*
|
|
2643
|
+
* Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
|
|
2644
|
+
* no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
|
|
2645
|
+
* via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
|
|
2646
|
+
* result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
|
|
2647
|
+
* trigger a second, redundant `resume` call on the same run.
|
|
2648
|
+
*/
|
|
2649
|
+
async reconcileSignalWaiter(run, waiter) {
|
|
2650
|
+
if (!waiter) return void 0;
|
|
2651
|
+
if (isEventToken(waiter.token)) return this.reconcileBufferedEvent(run, waiter);
|
|
2652
|
+
const buffered = await this.store.takeBufferedSignal(waiter.token);
|
|
2653
|
+
if (!buffered) return void 0;
|
|
2654
|
+
await this.store.removeSignalWaiter(waiter);
|
|
2655
|
+
const settled = await this.deliverSignal(waiter, buffered.payload);
|
|
2656
|
+
return settled ?? {
|
|
2657
|
+
runId: run.id,
|
|
2658
|
+
status: run.status
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* The EVENT half of {@link reconcileSignalWaiter}: scan this waiter's name for a matching buffered
|
|
2663
|
+
* event (same list+evaluate+claim shape as `waitForEvent`'s own consumption — the match predicate
|
|
2664
|
+
* belongs to the waiter, never the store), pruning any expired candidates it encounters along the
|
|
2665
|
+
* way when {@link eventBufferTtlMs} is configured. A pruned-but-non-matching candidate does not stop
|
|
2666
|
+
* the scan; only a MATCHING hit (or exhausting the scan window) does.
|
|
2667
|
+
*/
|
|
2668
|
+
async reconcileBufferedEvent(run, waiter) {
|
|
2669
|
+
const name = eventNameOf(waiter.token);
|
|
2670
|
+
const match = eventMatchOf(waiter.token);
|
|
2671
|
+
const candidates = await this.store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
|
|
2672
|
+
const nowMs = this.clock();
|
|
2673
|
+
for (const candidate of candidates) {
|
|
2674
|
+
if (this.eventBufferTtlMs != null && nowMs - candidate.publishedAt > this.eventBufferTtlMs) {
|
|
2675
|
+
await this.store.removeBufferedEvent(candidate.id);
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2678
|
+
if (!eventMatches(candidate.payload, match)) continue;
|
|
2679
|
+
if (!await this.store.removeBufferedEvent(candidate.id)) continue;
|
|
2680
|
+
await this.store.removeSignalWaiter(waiter);
|
|
2681
|
+
const settled = await this.deliverSignal(waiter, candidate.payload);
|
|
2682
|
+
return settled ?? {
|
|
2683
|
+
runId: run.id,
|
|
2684
|
+
status: run.status
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
return void 0;
|
|
2514
2688
|
}
|
|
2515
2689
|
/**
|
|
2516
2690
|
* Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
|
|
@@ -2700,6 +2874,21 @@ var WorkflowEngine = class {
|
|
|
2700
2874
|
* 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
|
|
2701
2875
|
* payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
|
|
2702
2876
|
* of the same logical event (default: a fresh uuid, so each publish triggers once).
|
|
2877
|
+
*
|
|
2878
|
+
* Semantics (deliberate — mirrors `signalWithStart`'s reliability contract, so document it exactly):
|
|
2879
|
+
* a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator / starts ≥1
|
|
2880
|
+
* subscriber, behaves exactly as above and is NOT buffered — fan-out stays live-only, same as
|
|
2881
|
+
* today. A publish that touches NEITHER — no live waiter matched, no subscriber exists/started —
|
|
2882
|
+
* buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
|
|
2883
|
+
* silently dropped just because nobody happened to be listening yet. That buffered copy is consumed
|
|
2884
|
+
* by the FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload —
|
|
2885
|
+
* point-to-point on redelivery, by design (only one waiter ever gets it, even though the LIVE path
|
|
2886
|
+
* above is fan-out) — never by a second, later-registered onEvent subscriber (subscriber starts stay
|
|
2887
|
+
* live-only; `opts.id` dedupe applies to those only, unchanged). Right after buffering, this re-checks
|
|
2888
|
+
* `listSignalWaiters` ONCE — sandwich parity with `signal`'s own take → buffer → re-check — so a
|
|
2889
|
+
* waiter that registers in the sliver between the initial miss and the buffer write is still paired
|
|
2890
|
+
* instead of leaving both rows stranded (see `signal`'s interleaving proof; `waitForEvent`'s own
|
|
2891
|
+
* post-registration buffer scan is the mirror-image half of this same race).
|
|
2703
2892
|
*/
|
|
2704
2893
|
async publishEvent(name, payload, opts) {
|
|
2705
2894
|
let touched = 0;
|
|
@@ -2726,18 +2915,36 @@ var WorkflowEngine = class {
|
|
|
2726
2915
|
}
|
|
2727
2916
|
}
|
|
2728
2917
|
}
|
|
2918
|
+
if (touched > 0 || opts?.buffer === false) return touched;
|
|
2919
|
+
const bufferedId = globalThis.crypto.randomUUID();
|
|
2920
|
+
await this.store.bufferEvent({
|
|
2921
|
+
name,
|
|
2922
|
+
payload,
|
|
2923
|
+
id: bufferedId,
|
|
2924
|
+
publishedAt: this.clock()
|
|
2925
|
+
});
|
|
2926
|
+
const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
|
|
2927
|
+
const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
|
|
2928
|
+
if (!lateWaiter) return touched;
|
|
2929
|
+
if (!await this.store.removeBufferedEvent(bufferedId)) return touched;
|
|
2930
|
+
const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
|
|
2931
|
+
if (waiter) {
|
|
2932
|
+
const settled = await this.deliverSignal(waiter, payload);
|
|
2933
|
+
if (settled) touched += 1;
|
|
2934
|
+
}
|
|
2729
2935
|
return touched;
|
|
2730
2936
|
}
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2937
|
+
/**
|
|
2938
|
+
* Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
|
|
2939
|
+
* resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
|
|
2940
|
+
* due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
|
|
2941
|
+
* same way.
|
|
2942
|
+
*/
|
|
2943
|
+
async deliverSignal(waiter, payload) {
|
|
2737
2944
|
await this.store.saveCheckpoint(instantCheckpoint({
|
|
2738
2945
|
runId: waiter.runId,
|
|
2739
2946
|
seq: waiter.seq,
|
|
2740
|
-
name: `signal:${token}`,
|
|
2947
|
+
name: `signal:${waiter.token}`,
|
|
2741
2948
|
kind: "signal",
|
|
2742
2949
|
output: payload,
|
|
2743
2950
|
// Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
|
|
@@ -2752,6 +2959,42 @@ var WorkflowEngine = class {
|
|
|
2752
2959
|
});
|
|
2753
2960
|
}
|
|
2754
2961
|
/**
|
|
2962
|
+
* Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
|
|
2963
|
+
* the run result — or null if no run is (or ends up) waiting for it.
|
|
2964
|
+
*
|
|
2965
|
+
* Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
|
|
2966
|
+
* the remote `waitSignal` command): a waiter can register in the sliver between this method's
|
|
2967
|
+
* initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
|
|
2968
|
+
* return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
|
|
2969
|
+
* nothing left to pair them — a lost wake, forever suspended.
|
|
2970
|
+
*
|
|
2971
|
+
* The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
|
|
2972
|
+
* → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
|
|
2973
|
+
* write, because it always follows that write in real time:
|
|
2974
|
+
* - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
|
|
2975
|
+
* (the pre-existing, non-racy path).
|
|
2976
|
+
* - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
|
|
2977
|
+
* it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
|
|
2978
|
+
* buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
|
|
2979
|
+
* re-check strictly follows its own put).
|
|
2980
|
+
* - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
|
|
2981
|
+
* catches it.
|
|
2982
|
+
* Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
|
|
2983
|
+
* side actually removes the row is the one that delivers, so a signal is never delivered twice.
|
|
2984
|
+
*/
|
|
2985
|
+
async signal(token, payload) {
|
|
2986
|
+
const waiter = await this.store.takeSignalWaiter(token);
|
|
2987
|
+
if (waiter) return this.deliverSignal(waiter, payload);
|
|
2988
|
+
await this.store.bufferSignal(token, payload);
|
|
2989
|
+
const lateWaiter = await this.store.takeSignalWaiter(token);
|
|
2990
|
+
if (!lateWaiter) return null;
|
|
2991
|
+
const reclaimed = await this.store.takeBufferedSignal(token);
|
|
2992
|
+
if (!reclaimed) {
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
return this.deliverSignal(lateWaiter, reclaimed.payload);
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2755
2998
|
* Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
|
|
2756
2999
|
* signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
|
|
2757
3000
|
* consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
|
|
@@ -3224,6 +3467,14 @@ var WorkflowEngine = class {
|
|
|
3224
3467
|
error: outcome.error
|
|
3225
3468
|
};
|
|
3226
3469
|
}
|
|
3470
|
+
const latest = await this.store.getRun(run.id);
|
|
3471
|
+
if (latest?.status === "cancelled") {
|
|
3472
|
+
return {
|
|
3473
|
+
runId: run.id,
|
|
3474
|
+
status: "cancelled",
|
|
3475
|
+
error: latest.error
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3227
3478
|
await this.store.updateRun(run.id, {
|
|
3228
3479
|
status: "suspended",
|
|
3229
3480
|
wakeAt: this.reconcileWakeAt(outcome.wakeAt),
|
|
@@ -3566,22 +3817,34 @@ var WorkflowEngine = class {
|
|
|
3566
3817
|
}));
|
|
3567
3818
|
wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
|
|
3568
3819
|
} else if (cmd.kind === "waitSignal") {
|
|
3569
|
-
const
|
|
3570
|
-
if (buffered) {
|
|
3820
|
+
const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
|
|
3571
3821
|
await this.store.saveCheckpoint(instantCheckpoint({
|
|
3572
3822
|
runId: run.id,
|
|
3573
3823
|
seq: cmd.seq,
|
|
3574
3824
|
name: `signal:${cmd.signal}`,
|
|
3575
3825
|
kind: "signal",
|
|
3576
|
-
output:
|
|
3826
|
+
output: payload
|
|
3577
3827
|
}));
|
|
3578
3828
|
setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
|
|
3829
|
+
}, "deliverBuffered");
|
|
3830
|
+
const buffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
3831
|
+
if (buffered) {
|
|
3832
|
+
await deliverBuffered(buffered.payload);
|
|
3579
3833
|
} else {
|
|
3580
3834
|
await this.store.putSignalWaiter({
|
|
3581
3835
|
token: cmd.signal,
|
|
3582
3836
|
runId: run.id,
|
|
3583
3837
|
seq: cmd.seq
|
|
3584
3838
|
});
|
|
3839
|
+
const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
|
|
3840
|
+
if (lateBuffered) {
|
|
3841
|
+
await this.store.removeSignalWaiter({
|
|
3842
|
+
token: cmd.signal,
|
|
3843
|
+
runId: run.id,
|
|
3844
|
+
seq: cmd.seq
|
|
3845
|
+
});
|
|
3846
|
+
await deliverBuffered(lateBuffered.payload);
|
|
3847
|
+
}
|
|
3585
3848
|
}
|
|
3586
3849
|
} else if (cmd.kind === "startChild") {
|
|
3587
3850
|
const childId = `${run.id}.child.${cmd.seq}`;
|
|
@@ -4441,47 +4704,6 @@ var TERMINAL_RUN_STATUSES = [
|
|
|
4441
4704
|
"dead"
|
|
4442
4705
|
];
|
|
4443
4706
|
|
|
4444
|
-
// src/run-waiting.ts
|
|
4445
|
-
var WEBHOOK_PREFIX = "wh:";
|
|
4446
|
-
var CHILD_PREFIX = "child:";
|
|
4447
|
-
var BREAKPOINT_PREFIX = "bp:";
|
|
4448
|
-
var EVENT_PREFIX = "event:";
|
|
4449
|
-
function classifyWaiterToken(token) {
|
|
4450
|
-
if (token.startsWith(WEBHOOK_PREFIX)) return {
|
|
4451
|
-
on: "webhook",
|
|
4452
|
-
name: token
|
|
4453
|
-
};
|
|
4454
|
-
if (token.startsWith(BREAKPOINT_PREFIX)) return {
|
|
4455
|
-
on: "breakpoint",
|
|
4456
|
-
name: "breakpoint"
|
|
4457
|
-
};
|
|
4458
|
-
if (token.startsWith(CHILD_PREFIX)) return {
|
|
4459
|
-
on: "child",
|
|
4460
|
-
name: token.slice(CHILD_PREFIX.length)
|
|
4461
|
-
};
|
|
4462
|
-
if (token.startsWith(EVENT_PREFIX)) return {
|
|
4463
|
-
on: "signal",
|
|
4464
|
-
name: eventNameOf(token)
|
|
4465
|
-
};
|
|
4466
|
-
return {
|
|
4467
|
-
on: "signal",
|
|
4468
|
-
name: token
|
|
4469
|
-
};
|
|
4470
|
-
}
|
|
4471
|
-
__name(classifyWaiterToken, "classifyWaiterToken");
|
|
4472
|
-
function resolveRunWaiting(run, waiterByRun) {
|
|
4473
|
-
if (run.status !== "suspended") return void 0;
|
|
4474
|
-
const waiter = waiterByRun.get(run.id);
|
|
4475
|
-
return waiter ? classifyWaiterToken(waiter.token) : void 0;
|
|
4476
|
-
}
|
|
4477
|
-
__name(resolveRunWaiting, "resolveRunWaiting");
|
|
4478
|
-
function indexWaitersByRun(waiters) {
|
|
4479
|
-
const byRun = /* @__PURE__ */ new Map();
|
|
4480
|
-
for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
|
|
4481
|
-
return byRun;
|
|
4482
|
-
}
|
|
4483
|
-
__name(indexWaitersByRun, "indexWaitersByRun");
|
|
4484
|
-
|
|
4485
4707
|
// src/codec-state-store.ts
|
|
4486
4708
|
var CodecStateStore = class {
|
|
4487
4709
|
static {
|
|
@@ -4589,6 +4811,9 @@ var CodecStateStore = class {
|
|
|
4589
4811
|
listSignalWaiters(prefix) {
|
|
4590
4812
|
return this.inner.listSignalWaiters(prefix);
|
|
4591
4813
|
}
|
|
4814
|
+
removeSignalWaiter(waiter) {
|
|
4815
|
+
return this.inner.removeSignalWaiter(waiter);
|
|
4816
|
+
}
|
|
4592
4817
|
bufferSignal(token, payload) {
|
|
4593
4818
|
return this.inner.bufferSignal(token, this.enc(payload));
|
|
4594
4819
|
}
|
|
@@ -4598,6 +4823,22 @@ var CodecStateStore = class {
|
|
|
4598
4823
|
payload: this.dec(buffered.payload)
|
|
4599
4824
|
};
|
|
4600
4825
|
}
|
|
4826
|
+
bufferEvent(input) {
|
|
4827
|
+
return this.inner.bufferEvent({
|
|
4828
|
+
...input,
|
|
4829
|
+
payload: this.enc(input.payload)
|
|
4830
|
+
});
|
|
4831
|
+
}
|
|
4832
|
+
async listBufferedEvents(name, limit) {
|
|
4833
|
+
const events = await this.inner.listBufferedEvents(name, limit);
|
|
4834
|
+
return events.map((e) => ({
|
|
4835
|
+
...e,
|
|
4836
|
+
payload: this.dec(e.payload)
|
|
4837
|
+
}));
|
|
4838
|
+
}
|
|
4839
|
+
removeBufferedEvent(id) {
|
|
4840
|
+
return this.inner.removeBufferedEvent(id);
|
|
4841
|
+
}
|
|
4601
4842
|
async listRuns(query) {
|
|
4602
4843
|
return (await this.inner.listRuns(query)).map((r) => this.decRun(r));
|
|
4603
4844
|
}
|
|
@@ -5041,6 +5282,12 @@ var InMemoryStateStore = class {
|
|
|
5041
5282
|
...w
|
|
5042
5283
|
}));
|
|
5043
5284
|
}
|
|
5285
|
+
async removeSignalWaiter(waiter) {
|
|
5286
|
+
const current = this.signalWaiters.get(waiter.token);
|
|
5287
|
+
if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
|
|
5288
|
+
this.signalWaiters.delete(waiter.token);
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5044
5291
|
// No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
|
|
5045
5292
|
// across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
|
|
5046
5293
|
async transaction(work) {
|
|
@@ -5064,6 +5311,28 @@ var InMemoryStateStore = class {
|
|
|
5064
5311
|
payload
|
|
5065
5312
|
};
|
|
5066
5313
|
}
|
|
5314
|
+
/** `id -> row`, so {@link removeBufferedEvent} can delete an exact row regardless of iteration
|
|
5315
|
+
* order — mirrors the SQL stores' PK-by-id delete. */
|
|
5316
|
+
bufferedEvents = /* @__PURE__ */ new Map();
|
|
5317
|
+
async bufferEvent(input) {
|
|
5318
|
+
this.bufferedEvents.set(input.id, {
|
|
5319
|
+
name: input.name,
|
|
5320
|
+
payload: input.payload,
|
|
5321
|
+
publishedAt: input.publishedAt
|
|
5322
|
+
});
|
|
5323
|
+
}
|
|
5324
|
+
async listBufferedEvents(name, limit) {
|
|
5325
|
+
return [
|
|
5326
|
+
...this.bufferedEvents.entries()
|
|
5327
|
+
].filter(([, e]) => e.name === name).sort((a, b) => a[1].publishedAt - b[1].publishedAt).slice(0, limit).map(([id, e]) => ({
|
|
5328
|
+
id,
|
|
5329
|
+
payload: e.payload,
|
|
5330
|
+
publishedAt: e.publishedAt
|
|
5331
|
+
}));
|
|
5332
|
+
}
|
|
5333
|
+
async removeBufferedEvent(id) {
|
|
5334
|
+
return this.bufferedEvents.delete(id);
|
|
5335
|
+
}
|
|
5067
5336
|
/**
|
|
5068
5337
|
* Candidate run ids satisfying ONE attribute predicate, served from {@link attributeIndex} — the
|
|
5069
5338
|
* pushdown step. Numeric predicates scan only `numValue` rows under the key; string/boolean ones
|
|
@@ -5222,6 +5491,8 @@ export {
|
|
|
5222
5491
|
DURABLE_STEP_CONFIG,
|
|
5223
5492
|
DURABLE_STEP_NAME,
|
|
5224
5493
|
DurableWorkflow,
|
|
5494
|
+
EVENT_BUFFER_SCAN_LIMIT,
|
|
5495
|
+
EVENT_TOKEN_PREFIX,
|
|
5225
5496
|
Entities,
|
|
5226
5497
|
EventAccumulators,
|
|
5227
5498
|
FatalError,
|
|
@@ -5260,6 +5531,7 @@ export {
|
|
|
5260
5531
|
eventPrefix,
|
|
5261
5532
|
eventToken,
|
|
5262
5533
|
indexWaitersByRun,
|
|
5534
|
+
isEventToken,
|
|
5263
5535
|
isWorkflowControlFlowSignal,
|
|
5264
5536
|
matchesAttributes,
|
|
5265
5537
|
normalizeAttributeRows,
|