@dudousxd/nestjs-durable-core 0.81.0 → 0.83.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 +361 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +271 -23
- package/dist/index.d.ts +271 -23
- package/dist/index.js +356 -38
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -277,6 +277,31 @@ var FatalError = class extends Error {
|
|
|
277
277
|
this.details = details;
|
|
278
278
|
}
|
|
279
279
|
};
|
|
280
|
+
var SchemaValidationError = class extends FatalError {
|
|
281
|
+
static {
|
|
282
|
+
__name(this, "SchemaValidationError");
|
|
283
|
+
}
|
|
284
|
+
static code = "schema_validation";
|
|
285
|
+
issues;
|
|
286
|
+
/**
|
|
287
|
+
* @param label What was validated, e.g. `workflow "checkout" input`; the message is
|
|
288
|
+
* `invalid <label>: <path>: <message>; …`.
|
|
289
|
+
* @param summary Overrides the `<path>: <message>; …` part, for issues whose messages already
|
|
290
|
+
* name their field (class-validator's `orderId must be a string`).
|
|
291
|
+
*/
|
|
292
|
+
constructor(label, issues, code = "schema_validation", summary) {
|
|
293
|
+
const normalized = issues.map((issue) => ({
|
|
294
|
+
message: issue.message,
|
|
295
|
+
path: (issue.path ?? []).map((segment) => String(typeof segment === "object" && segment !== null ? segment.key : segment))
|
|
296
|
+
}));
|
|
297
|
+
const text = summary ?? normalized.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
|
|
298
|
+
super(`invalid ${label}: ${text}`, code, {
|
|
299
|
+
issues: normalized
|
|
300
|
+
});
|
|
301
|
+
this.name = "SchemaValidationError";
|
|
302
|
+
this.issues = normalized;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
280
305
|
var ConcurrencyLimitError = class extends Error {
|
|
281
306
|
static {
|
|
282
307
|
__name(this, "ConcurrencyLimitError");
|
|
@@ -2008,9 +2033,14 @@ function liveHandoff(runId, checkpoints) {
|
|
|
2008
2033
|
return live;
|
|
2009
2034
|
}
|
|
2010
2035
|
__name(liveHandoff, "liveHandoff");
|
|
2036
|
+
var SCHEMA_VALIDATION_CODES = /* @__PURE__ */ new Set([
|
|
2037
|
+
"invalid_input",
|
|
2038
|
+
"invalid_output"
|
|
2039
|
+
]);
|
|
2011
2040
|
function routeFor(routes, error2) {
|
|
2012
2041
|
if (!routes?.length || error2.code && UNROUTABLE_CODES.has(error2.code)) return void 0;
|
|
2013
|
-
|
|
2042
|
+
const { code } = error2;
|
|
2043
|
+
return (code ? routes.find((r) => r.code === code) : void 0) ?? (code && SCHEMA_VALIDATION_CODES.has(code) ? routes.find((r) => r.code === "schema_validation") : void 0) ?? routes.find((r) => r.code === void 0);
|
|
2014
2044
|
}
|
|
2015
2045
|
__name(routeFor, "routeFor");
|
|
2016
2046
|
function assertRoutes(workflow, routes) {
|
|
@@ -2282,9 +2312,13 @@ __name(scheduleRunIdsAt, "scheduleRunIdsAt");
|
|
|
2282
2312
|
async function runSchedules(engine, schedules, nowMs, opts) {
|
|
2283
2313
|
const random = opts?.random ?? Math.random;
|
|
2284
2314
|
const sleep = opts?.sleep ?? defaultSleep;
|
|
2315
|
+
const settled = settledIdsFor(engine);
|
|
2285
2316
|
const ids = [];
|
|
2286
2317
|
for (const s of schedules) {
|
|
2318
|
+
if (engine.isDraining?.()) break;
|
|
2287
2319
|
if (s.paused) continue;
|
|
2320
|
+
const candidates = scheduleRunIdsAt(s, nowMs).filter((id) => !settled.get(s.key)?.has(id));
|
|
2321
|
+
if (candidates.length === 0) continue;
|
|
2288
2322
|
if (s.overlap === "skip" && s.everyMs) {
|
|
2289
2323
|
const prevBucket = Math.floor(nowMs / s.everyMs) - 1;
|
|
2290
2324
|
if (prevBucket >= 0) {
|
|
@@ -2293,14 +2327,44 @@ async function runSchedules(engine, schedules, nowMs, opts) {
|
|
|
2293
2327
|
}
|
|
2294
2328
|
}
|
|
2295
2329
|
if (s.jitter && s.jitter > 0) await sleep(Math.floor(random() * s.jitter));
|
|
2296
|
-
for (const runId of
|
|
2297
|
-
|
|
2330
|
+
for (const runId of candidates) {
|
|
2331
|
+
if (engine.isDraining?.()) break;
|
|
2332
|
+
const existing = await engine.getRun(runId);
|
|
2333
|
+
remember(settled, s.key, runId);
|
|
2334
|
+
if (existing) continue;
|
|
2335
|
+
await engine.start(workflowName(s.workflow), s.input, runId, {
|
|
2336
|
+
namespace: s.namespace
|
|
2337
|
+
});
|
|
2298
2338
|
ids.push(runId);
|
|
2299
2339
|
}
|
|
2300
2340
|
}
|
|
2301
2341
|
return ids;
|
|
2302
2342
|
}
|
|
2303
2343
|
__name(runSchedules, "runSchedules");
|
|
2344
|
+
var settledRunIds = /* @__PURE__ */ new WeakMap();
|
|
2345
|
+
function settledIdsFor(engine) {
|
|
2346
|
+
let memo = settledRunIds.get(engine);
|
|
2347
|
+
if (memo === void 0) {
|
|
2348
|
+
memo = /* @__PURE__ */ new Map();
|
|
2349
|
+
settledRunIds.set(engine, memo);
|
|
2350
|
+
}
|
|
2351
|
+
return memo;
|
|
2352
|
+
}
|
|
2353
|
+
__name(settledIdsFor, "settledIdsFor");
|
|
2354
|
+
function remember(memo, key, runId) {
|
|
2355
|
+
const seen = memo.get(key);
|
|
2356
|
+
if (seen === void 0) {
|
|
2357
|
+
memo.set(key, /* @__PURE__ */ new Set([
|
|
2358
|
+
runId
|
|
2359
|
+
]));
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
seen.add(runId);
|
|
2363
|
+
if (seen.size > 64) memo.set(key, /* @__PURE__ */ new Set([
|
|
2364
|
+
runId
|
|
2365
|
+
]));
|
|
2366
|
+
}
|
|
2367
|
+
__name(remember, "remember");
|
|
2304
2368
|
|
|
2305
2369
|
// src/schedules.ts
|
|
2306
2370
|
var ScheduleAlreadyExistsError = class extends Error {
|
|
@@ -2816,6 +2880,66 @@ var SingletonGate = class {
|
|
|
2816
2880
|
}
|
|
2817
2881
|
};
|
|
2818
2882
|
|
|
2883
|
+
// src/stalled-runs.ts
|
|
2884
|
+
var DEFAULT_STALLED_AFTER_MS = 15 * 6e4;
|
|
2885
|
+
async function findStalledRuns(store, options = {}) {
|
|
2886
|
+
const now = options.now ?? Date.now();
|
|
2887
|
+
const thresholdMs = options.thresholdMs ?? DEFAULT_STALLED_AFTER_MS;
|
|
2888
|
+
const scan = Math.max(1, options.scan ?? 1e3);
|
|
2889
|
+
const pageSize = Math.min(200, scan);
|
|
2890
|
+
const stalled = [];
|
|
2891
|
+
for (let offset = 0; offset < scan; offset += pageSize) {
|
|
2892
|
+
const limit = Math.min(pageSize, scan - offset);
|
|
2893
|
+
const page = await store.listRuns({
|
|
2894
|
+
statuses: [
|
|
2895
|
+
"running",
|
|
2896
|
+
"suspended"
|
|
2897
|
+
],
|
|
2898
|
+
...options.namespace !== void 0 ? {
|
|
2899
|
+
namespace: options.namespace
|
|
2900
|
+
} : {},
|
|
2901
|
+
limit,
|
|
2902
|
+
offset
|
|
2903
|
+
});
|
|
2904
|
+
for (const run of page) {
|
|
2905
|
+
const ageMs = now - run.updatedAt.getTime();
|
|
2906
|
+
if (ageMs < thresholdMs) continue;
|
|
2907
|
+
const info2 = await stalledInfo(store, run, now, thresholdMs, ageMs);
|
|
2908
|
+
if (info2) stalled.push(info2);
|
|
2909
|
+
}
|
|
2910
|
+
if (page.length < limit) break;
|
|
2911
|
+
}
|
|
2912
|
+
return stalled;
|
|
2913
|
+
}
|
|
2914
|
+
__name(findStalledRuns, "findStalledRuns");
|
|
2915
|
+
async function stalledInfo(store, run, now, thresholdMs, ageMs) {
|
|
2916
|
+
let stalePending = null;
|
|
2917
|
+
for (const cp of await store.listCheckpoints(run.id)) {
|
|
2918
|
+
if (cp.kind !== "remote" || cp.status !== "pending") continue;
|
|
2919
|
+
const stepAgeMs = now - cp.enqueuedAt.getTime();
|
|
2920
|
+
if (stepAgeMs < thresholdMs) continue;
|
|
2921
|
+
if (cp.wakeAt != null && cp.wakeAt > now) continue;
|
|
2922
|
+
if (!stalePending || stepAgeMs > stalePending.ageMs) {
|
|
2923
|
+
stalePending = {
|
|
2924
|
+
seq: cp.seq,
|
|
2925
|
+
name: cp.name,
|
|
2926
|
+
attempts: cp.attempts,
|
|
2927
|
+
ageMs: stepAgeMs,
|
|
2928
|
+
leaseUntil: cp.wakeAt ?? null
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
const wakeForever = run.status === "suspended" && run.wakeAt == null;
|
|
2933
|
+
if (!wakeForever && !stalePending) return null;
|
|
2934
|
+
return {
|
|
2935
|
+
run,
|
|
2936
|
+
ageMs,
|
|
2937
|
+
wakeForever,
|
|
2938
|
+
stalePending
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
__name(stalledInfo, "stalledInfo");
|
|
2942
|
+
|
|
2819
2943
|
// src/transport-pool.ts
|
|
2820
2944
|
var TransportPool = class {
|
|
2821
2945
|
static {
|
|
@@ -3039,11 +3163,6 @@ function normalizeChildOptions(options) {
|
|
|
3039
3163
|
} : options ?? {};
|
|
3040
3164
|
}
|
|
3041
3165
|
__name(normalizeChildOptions, "normalizeChildOptions");
|
|
3042
|
-
function formatIssuePath(path) {
|
|
3043
|
-
if (!path || path.length === 0) return "(root)";
|
|
3044
|
-
return path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
3045
|
-
}
|
|
3046
|
-
__name(formatIssuePath, "formatIssuePath");
|
|
3047
3166
|
function resolveStepPolicy(config, opts) {
|
|
3048
3167
|
const policy = {};
|
|
3049
3168
|
const retries = opts?.retries ?? config?.retries;
|
|
@@ -3875,11 +3994,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
|
|
|
3875
3994
|
throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes schema validation must be synchronous (the searchAttributes schema's \`~standard.validate\` returned a Promise) \u2014 replay-determinism requires a sync validator; drop any async refinement from the schema passed to @Workflow({ searchAttributes }).`);
|
|
3876
3995
|
}
|
|
3877
3996
|
if (result.issues) {
|
|
3878
|
-
|
|
3879
|
-
...new Set(result.issues.map((issue) => formatIssuePath(issue.path)))
|
|
3880
|
-
];
|
|
3881
|
-
const details = result.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`).join("; ");
|
|
3882
|
-
throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes rejected by its searchAttributes schema \u2014 offending key(s) ${offendingKeys.join(", ")} \u2014 ${details}`);
|
|
3997
|
+
throw new SchemaValidationError(`workflow "${workflow}" ctx.upsertSearchAttributes`, result.issues);
|
|
3883
3998
|
}
|
|
3884
3999
|
}
|
|
3885
4000
|
await host.upsertSearchAttributes(runId, attrs);
|
|
@@ -4173,6 +4288,18 @@ var WorkflowEngine = class {
|
|
|
4173
4288
|
stepQueue = /* @__PURE__ */ new Map();
|
|
4174
4289
|
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
4175
4290
|
inflight = /* @__PURE__ */ new Set();
|
|
4291
|
+
/**
|
|
4292
|
+
* The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
|
|
4293
|
+
* and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
|
|
4294
|
+
* releases when its budget runs out.
|
|
4295
|
+
*/
|
|
4296
|
+
executionLeases = /* @__PURE__ */ new Map();
|
|
4297
|
+
/** Pager hooks for stranded runs (see {@link onStalled}). */
|
|
4298
|
+
stalledListeners = /* @__PURE__ */ new Set();
|
|
4299
|
+
/** Runs already paged about, by id → the `updatedAt` of the paged episode (see {@link sweepStalled}). */
|
|
4300
|
+
notifiedStalled = /* @__PURE__ */ new Map();
|
|
4301
|
+
lastStalledSweepAt = Number.NEGATIVE_INFINITY;
|
|
4302
|
+
stalledAfterMs;
|
|
4176
4303
|
draining = false;
|
|
4177
4304
|
/** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
|
|
4178
4305
|
drainSpentMs = 0;
|
|
@@ -4197,6 +4324,8 @@ var WorkflowEngine = class {
|
|
|
4197
4324
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
4198
4325
|
this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
|
|
4199
4326
|
this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
|
|
4327
|
+
const stalledAfter = deps.stalledAfterMs;
|
|
4328
|
+
this.stalledAfterMs = stalledAfter !== void 0 && Number.isFinite(stalledAfter) ? Math.max(6e4, stalledAfter) : DEFAULT_STALLED_AFTER_MS;
|
|
4200
4329
|
const batch = deps.timerBatchSize;
|
|
4201
4330
|
this.timerBatchSize = batch !== void 0 && Number.isFinite(batch) && batch >= 1 ? Math.floor(batch) : 500;
|
|
4202
4331
|
this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
|
|
@@ -4540,6 +4669,64 @@ var WorkflowEngine = class {
|
|
|
4540
4669
|
this.deadListeners.add(listener);
|
|
4541
4670
|
return () => this.deadListeners.delete(listener);
|
|
4542
4671
|
}
|
|
4672
|
+
/**
|
|
4673
|
+
* Register a pager hook for STRANDED runs: an in-flight run untouched past `stalledAfterMs` that is
|
|
4674
|
+
* either suspended with NO wake timer (wake-forever: nothing will ever re-drive it) or parked on an
|
|
4675
|
+
* old pending remote step with no live lease (dispatched and, most likely, lost). The timer poller
|
|
4676
|
+
* sweeps for these ({@link sweepStalled}, throttled to one pass a minute) and notifies each episode
|
|
4677
|
+
* ONCE per instance: a run is paged again only after it makes progress and strands again. Wire your
|
|
4678
|
+
* alerting here like {@link onDead}. A listener that throws never breaks the sweep. Returns an
|
|
4679
|
+
* unsubscribe function.
|
|
4680
|
+
*/
|
|
4681
|
+
onStalled(listener) {
|
|
4682
|
+
this.stalledListeners.add(listener);
|
|
4683
|
+
return () => this.stalledListeners.delete(listener);
|
|
4684
|
+
}
|
|
4685
|
+
/**
|
|
4686
|
+
* Detect stranded in-flight runs and notify the {@link onStalled} listeners. Self-throttled to one
|
|
4687
|
+
* pass a minute, skipped while draining, and a no-op without listeners — call it every tick (the
|
|
4688
|
+
* NestJS timer poller does). Scoped to this engine's `namespace` (an operator engine with no
|
|
4689
|
+
* namespace sweeps every namespace). Returns the runs notified THIS pass.
|
|
4690
|
+
*/
|
|
4691
|
+
async sweepStalled(now = this.clock()) {
|
|
4692
|
+
if (this.stalledListeners.size === 0 || this.draining) return [];
|
|
4693
|
+
if (now - this.lastStalledSweepAt < 6e4) return [];
|
|
4694
|
+
this.lastStalledSweepAt = now;
|
|
4695
|
+
const notified = [];
|
|
4696
|
+
for (const info2 of await this.listStalledRuns({
|
|
4697
|
+
now
|
|
4698
|
+
})) {
|
|
4699
|
+
const episode = info2.run.updatedAt.getTime();
|
|
4700
|
+
if (this.notifiedStalled.get(info2.run.id) === episode) continue;
|
|
4701
|
+
this.notifiedStalled.set(info2.run.id, episode);
|
|
4702
|
+
if (this.notifiedStalled.size > 1e3) this.notifiedStalled.clear();
|
|
4703
|
+
for (const listener of this.stalledListeners) {
|
|
4704
|
+
try {
|
|
4705
|
+
listener(info2);
|
|
4706
|
+
} catch {
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
notified.push(info2);
|
|
4710
|
+
}
|
|
4711
|
+
return notified;
|
|
4712
|
+
}
|
|
4713
|
+
/**
|
|
4714
|
+
* The stranded runs right now, without notifying anyone: the query behind {@link sweepStalled} and
|
|
4715
|
+
* the CLI's `durable inspect --stale`. A `running`/`suspended` run qualifies when it has been
|
|
4716
|
+
* untouched for `thresholdMs` (default `stalledAfterMs`) AND either it is suspended with no wake
|
|
4717
|
+
* timer, or its oldest pending remote step is older than the threshold with no lease in the future
|
|
4718
|
+
* (a step with a `timeoutMs`, a heartbeat or `remoteRedispatchMs` keeps a lease; a long
|
|
4719
|
+
* `ctx.sleep` has a future `wakeAt`; neither is stranded). Scans up to `scan` candidates (default
|
|
4720
|
+
* 1000), newest first, in this engine's namespace.
|
|
4721
|
+
*/
|
|
4722
|
+
async listStalledRuns(options = {}) {
|
|
4723
|
+
return findStalledRuns(this.store, {
|
|
4724
|
+
now: options.now ?? this.clock(),
|
|
4725
|
+
thresholdMs: options.thresholdMs ?? this.stalledAfterMs,
|
|
4726
|
+
scan: options.scan,
|
|
4727
|
+
namespace: this.namespace
|
|
4728
|
+
});
|
|
4729
|
+
}
|
|
4543
4730
|
notifyDead(run) {
|
|
4544
4731
|
for (const fn of this.deadListeners) {
|
|
4545
4732
|
try {
|
|
@@ -4698,6 +4885,11 @@ var WorkflowEngine = class {
|
|
|
4698
4885
|
getRun(runId) {
|
|
4699
4886
|
return this.store.getRun(runId);
|
|
4700
4887
|
}
|
|
4888
|
+
/** A run's full checkpoint timeline, ordered by seq. A thin pass-through to the store (it is what
|
|
4889
|
+
* the testing kit's `captureHistory` reads, next to {@link getRun}). */
|
|
4890
|
+
listCheckpoints(runId) {
|
|
4891
|
+
return this.store.listCheckpoints(runId);
|
|
4892
|
+
}
|
|
4701
4893
|
/**
|
|
4702
4894
|
* Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
|
|
4703
4895
|
* driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
|
|
@@ -4783,6 +4975,8 @@ var WorkflowEngine = class {
|
|
|
4783
4975
|
* diagnosis.
|
|
4784
4976
|
*/
|
|
4785
4977
|
async releaseLeaseBestEffort(runId) {
|
|
4978
|
+
if (this.isFenced(runId)) return;
|
|
4979
|
+
this.handOffLease(runId);
|
|
4786
4980
|
try {
|
|
4787
4981
|
await this.store.releaseRunLock(runId);
|
|
4788
4982
|
} catch (err) {
|
|
@@ -5006,6 +5200,10 @@ var WorkflowEngine = class {
|
|
|
5006
5200
|
for (const rearm of this.drainRearms) rearm();
|
|
5007
5201
|
}
|
|
5008
5202
|
}
|
|
5203
|
+
/** True once {@link beginDrain} (or {@link drain}) has been called. */
|
|
5204
|
+
isDraining() {
|
|
5205
|
+
return this.draining;
|
|
5206
|
+
}
|
|
5009
5207
|
/**
|
|
5010
5208
|
* Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
|
|
5011
5209
|
* in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
|
|
@@ -5032,17 +5230,47 @@ var WorkflowEngine = class {
|
|
|
5032
5230
|
rearm();
|
|
5033
5231
|
});
|
|
5034
5232
|
this.drainRearms.add(rearm);
|
|
5233
|
+
let settled = false;
|
|
5035
5234
|
try {
|
|
5036
5235
|
await Promise.race([
|
|
5037
5236
|
Promise.allSettled([
|
|
5038
5237
|
...this.inflight
|
|
5039
|
-
])
|
|
5238
|
+
]).then(() => {
|
|
5239
|
+
settled = true;
|
|
5240
|
+
}),
|
|
5040
5241
|
timer
|
|
5041
5242
|
]);
|
|
5042
5243
|
} finally {
|
|
5043
5244
|
this.drainRearms.delete(rearm);
|
|
5044
5245
|
clearTimeout(timeout);
|
|
5045
5246
|
}
|
|
5247
|
+
if (!settled) await this.releaseInflightLeases();
|
|
5248
|
+
}
|
|
5249
|
+
/**
|
|
5250
|
+
* The drain budget ran out with executions still in flight: release their recovery leases so
|
|
5251
|
+
* another instance re-drives those runs on its next recovery pass, instead of waiting out
|
|
5252
|
+
* `leaseMs` after this process exits. Each still-running turn is FENCED first, so whatever it
|
|
5253
|
+
* computes after this point is never written over the new owner's work (see settleRun). Its
|
|
5254
|
+
* completed steps are checkpointed, so the new owner replays them; a step still executing here
|
|
5255
|
+
* may run again there, exactly as after a crash. The release is owner-scoped where the store
|
|
5256
|
+
* supports it (only a lease this instance still holds is cleared). Best effort: never throws.
|
|
5257
|
+
*/
|
|
5258
|
+
async releaseInflightLeases() {
|
|
5259
|
+
const released = [];
|
|
5260
|
+
for (const [runId, lease] of [
|
|
5261
|
+
...this.executionLeases
|
|
5262
|
+
]) {
|
|
5263
|
+
if (lease.lost || lease.handedOff || lease.done) continue;
|
|
5264
|
+
lease.lost = true;
|
|
5265
|
+
try {
|
|
5266
|
+
await this.store.releaseRunLock(runId, this.instanceId);
|
|
5267
|
+
released.push(runId);
|
|
5268
|
+
} catch {
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5271
|
+
if (released.length > 0) {
|
|
5272
|
+
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(", ")}`);
|
|
5273
|
+
}
|
|
5046
5274
|
}
|
|
5047
5275
|
/**
|
|
5048
5276
|
* Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
|
|
@@ -6532,7 +6760,10 @@ var WorkflowEngine = class {
|
|
|
6532
6760
|
}
|
|
6533
6761
|
async singletonWaitResult(run) {
|
|
6534
6762
|
const current = await this.store.getRun(run.id);
|
|
6535
|
-
|
|
6763
|
+
if (!this.isFenced(run.id)) {
|
|
6764
|
+
this.handOffLease(run.id);
|
|
6765
|
+
await this.store.releaseRunLock(run.id);
|
|
6766
|
+
}
|
|
6536
6767
|
if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
|
|
6537
6768
|
if (current.status === "suspended") {
|
|
6538
6769
|
this.emit({
|
|
@@ -6560,10 +6791,7 @@ var WorkflowEngine = class {
|
|
|
6560
6791
|
};
|
|
6561
6792
|
}
|
|
6562
6793
|
}
|
|
6563
|
-
const
|
|
6564
|
-
void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
6565
|
-
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
6566
|
-
renew.unref?.();
|
|
6794
|
+
const held = this.holdExecutionLease(run.id);
|
|
6567
6795
|
try {
|
|
6568
6796
|
const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
|
|
6569
6797
|
if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
|
|
@@ -6571,10 +6799,59 @@ var WorkflowEngine = class {
|
|
|
6571
6799
|
}
|
|
6572
6800
|
return result;
|
|
6573
6801
|
} finally {
|
|
6574
|
-
|
|
6802
|
+
held.stop();
|
|
6575
6803
|
}
|
|
6576
6804
|
}
|
|
6577
6805
|
/**
|
|
6806
|
+
* Renew `runId`'s lease for as long as this execution holds it, and register it in
|
|
6807
|
+
* {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
|
|
6808
|
+
*
|
|
6809
|
+
* A renewal that returns `false` means the lease is gone: this turn stalled past `leaseMs` and
|
|
6810
|
+
* another instance reclaimed the run, so this executor is now a zombie. It is FENCED (`lost`): its
|
|
6811
|
+
* settles degrade to an echo of the store's state instead of clobbering the new owner's writes, and
|
|
6812
|
+
* its own lease release is skipped (it would free the new owner's lease). A renewal that THROWS (a
|
|
6813
|
+
* transient store error) is not evidence of a takeover, so it stays swallowed and the next
|
|
6814
|
+
* interval retries. A lease this execution released on purpose (`handedOff`) is not renewed, and a
|
|
6815
|
+
* renewal resolving after the execution ended (`done`) fences nothing.
|
|
6816
|
+
*/
|
|
6817
|
+
holdExecutionLease(runId) {
|
|
6818
|
+
const lease = {
|
|
6819
|
+
lost: false,
|
|
6820
|
+
handedOff: false,
|
|
6821
|
+
done: false
|
|
6822
|
+
};
|
|
6823
|
+
this.executionLeases.set(runId, lease);
|
|
6824
|
+
const renew = setInterval(() => {
|
|
6825
|
+
if (lease.handedOff || lease.lost || lease.done) return;
|
|
6826
|
+
void this.store.renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs).then((renewed) => {
|
|
6827
|
+
if (renewed || lease.handedOff || lease.lost || lease.done) return;
|
|
6828
|
+
lease.lost = true;
|
|
6829
|
+
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`);
|
|
6830
|
+
}).catch(() => void 0);
|
|
6831
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
6832
|
+
renew.unref?.();
|
|
6833
|
+
return {
|
|
6834
|
+
lease,
|
|
6835
|
+
stop: /* @__PURE__ */ __name(() => {
|
|
6836
|
+
clearInterval(renew);
|
|
6837
|
+
lease.done = true;
|
|
6838
|
+
if (this.executionLeases.get(runId) === lease) this.executionLeases.delete(runId);
|
|
6839
|
+
}, "stop")
|
|
6840
|
+
};
|
|
6841
|
+
}
|
|
6842
|
+
/** Whether `runId`'s execution on this instance lost its lease (see {@link holdExecutionLease}). */
|
|
6843
|
+
isFenced(runId) {
|
|
6844
|
+
return this.executionLeases.get(runId)?.lost === true;
|
|
6845
|
+
}
|
|
6846
|
+
/**
|
|
6847
|
+
* Mark `runId`'s lease as given away on purpose by its own execution (released mid-turn), so a
|
|
6848
|
+
* renewal that now fails is not read as a takeover.
|
|
6849
|
+
*/
|
|
6850
|
+
handOffLease(runId) {
|
|
6851
|
+
const lease = this.executionLeases.get(runId);
|
|
6852
|
+
if (lease) lease.handedOff = true;
|
|
6853
|
+
}
|
|
6854
|
+
/**
|
|
6578
6855
|
* Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
|
|
6579
6856
|
* a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
|
|
6580
6857
|
* {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
|
|
@@ -6791,6 +7068,7 @@ ${run.workflowVersion}`;
|
|
|
6791
7068
|
* keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
|
|
6792
7069
|
*/
|
|
6793
7070
|
async settleRun(run, outcome) {
|
|
7071
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6794
7072
|
const updatedAt = /* @__PURE__ */ new Date();
|
|
6795
7073
|
if (outcome.kind === "completed") {
|
|
6796
7074
|
const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
|
|
@@ -6871,6 +7149,7 @@ ${run.workflowVersion}`;
|
|
|
6871
7149
|
}
|
|
6872
7150
|
async runRemoteExecution(run, registered) {
|
|
6873
7151
|
if (run.status === "cancelling") {
|
|
7152
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6874
7153
|
const error2 = {
|
|
6875
7154
|
message: "cancelled"
|
|
6876
7155
|
};
|
|
@@ -6879,6 +7158,7 @@ ${run.workflowVersion}`;
|
|
|
6879
7158
|
error: error2,
|
|
6880
7159
|
updatedAt: /* @__PURE__ */ new Date()
|
|
6881
7160
|
});
|
|
7161
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6882
7162
|
this.emit({
|
|
6883
7163
|
type: "run.failed",
|
|
6884
7164
|
runId: run.id,
|
|
@@ -6925,6 +7205,7 @@ ${run.workflowVersion}`;
|
|
|
6925
7205
|
}
|
|
6926
7206
|
const history = await this.remoteHistory(run.id, checkpoints);
|
|
6927
7207
|
if (remote.executor.dispatch) {
|
|
7208
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6928
7209
|
const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
6929
7210
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
6930
7211
|
const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
|
|
@@ -6940,6 +7221,7 @@ ${run.workflowVersion}`;
|
|
|
6940
7221
|
workflow: run.workflow,
|
|
6941
7222
|
namespace: run.namespace
|
|
6942
7223
|
});
|
|
7224
|
+
this.handOffLease(run.id);
|
|
6943
7225
|
await this.store.releaseRunLock(run.id);
|
|
6944
7226
|
await remote.executor.dispatch(run, history, taskId);
|
|
6945
7227
|
return {
|
|
@@ -6968,6 +7250,7 @@ ${run.workflowVersion}`;
|
|
|
6968
7250
|
message: err instanceof Error ? err.message : String(err),
|
|
6969
7251
|
stack: err instanceof Error ? err.stack : void 0
|
|
6970
7252
|
};
|
|
7253
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6971
7254
|
await this.store.updateRun(run.id, {
|
|
6972
7255
|
status: "failed",
|
|
6973
7256
|
error: error2,
|
|
@@ -6996,6 +7279,7 @@ ${run.workflowVersion}`;
|
|
|
6996
7279
|
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
6997
7280
|
*/
|
|
6998
7281
|
async applyDecision(run, decision) {
|
|
7282
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
6999
7283
|
if (decision.status === "completed") {
|
|
7000
7284
|
await this.applyCommands(run, decision.commands);
|
|
7001
7285
|
return this.settleRun(run, {
|
|
@@ -7465,6 +7749,7 @@ ${run.workflowVersion}`;
|
|
|
7465
7749
|
* cancel flag stays too, re-derived from the status on any later re-drive regardless.
|
|
7466
7750
|
*/
|
|
7467
7751
|
async compensateAndCancel(run, compensations, replay) {
|
|
7752
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
7468
7753
|
const pending = await this.unwindCompensations(run, compensations, replay);
|
|
7469
7754
|
if (pending) return {
|
|
7470
7755
|
runId: run.id,
|
|
@@ -7556,6 +7841,7 @@ ${run.workflowVersion}`;
|
|
|
7556
7841
|
});
|
|
7557
7842
|
} catch (err) {
|
|
7558
7843
|
if (err instanceof ContinueAsNew) {
|
|
7844
|
+
if (this.isFenced(run.id)) return await this.echoCurrentStatus(run.id);
|
|
7559
7845
|
if (this.cancelRequested.has(run.id)) {
|
|
7560
7846
|
return await this.compensateAndCancel(run, compensations, replay);
|
|
7561
7847
|
}
|
|
@@ -8007,6 +8293,7 @@ ${run.workflowVersion}`;
|
|
|
8007
8293
|
* own `finally` also releases (the run-execution path) is safe.
|
|
8008
8294
|
*/
|
|
8009
8295
|
async parkBlocked(run, blocked) {
|
|
8296
|
+
if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
|
|
8010
8297
|
const error2 = {
|
|
8011
8298
|
message: blocked.reason,
|
|
8012
8299
|
code: blocked.code,
|
|
@@ -8593,10 +8880,7 @@ ${run.workflowVersion}`;
|
|
|
8593
8880
|
if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
|
|
8594
8881
|
return;
|
|
8595
8882
|
}
|
|
8596
|
-
const
|
|
8597
|
-
void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
8598
|
-
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
8599
|
-
renew.unref?.();
|
|
8883
|
+
const held = this.holdExecutionLease(decision.runId);
|
|
8600
8884
|
try {
|
|
8601
8885
|
const fresh = await this.store.getRun(decision.runId);
|
|
8602
8886
|
if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
|
|
@@ -8611,8 +8895,9 @@ ${run.workflowVersion}`;
|
|
|
8611
8895
|
fresh.awaitingDecisionTaskId = void 0;
|
|
8612
8896
|
await this.applyDecision(fresh, decision);
|
|
8613
8897
|
} finally {
|
|
8614
|
-
|
|
8615
|
-
|
|
8898
|
+
const fenced = held.lease.lost;
|
|
8899
|
+
held.stop();
|
|
8900
|
+
if (!fenced) await this.store.releaseRunLock(decision.runId);
|
|
8616
8901
|
}
|
|
8617
8902
|
}
|
|
8618
8903
|
/** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
|
|
@@ -8808,6 +9093,9 @@ function childIdsOf(checkpoints) {
|
|
|
8808
9093
|
}
|
|
8809
9094
|
__name(childIdsOf, "childIdsOf");
|
|
8810
9095
|
|
|
9096
|
+
// src/run-history.ts
|
|
9097
|
+
var RUN_HISTORY_FIXTURE_VERSION = 1;
|
|
9098
|
+
|
|
8811
9099
|
// src/run-facets.ts
|
|
8812
9100
|
function facetOrigin(origin) {
|
|
8813
9101
|
return origin?.trim() ? origin : null;
|
|
@@ -9361,13 +9649,14 @@ var CodecStateStore = class {
|
|
|
9361
9649
|
}
|
|
9362
9650
|
inner;
|
|
9363
9651
|
codec;
|
|
9652
|
+
extended;
|
|
9364
9653
|
/** Atomic claims touch only searchable metadata and bypass payload encoding. */
|
|
9365
9654
|
tryAdmitSingleton;
|
|
9366
9655
|
/**
|
|
9367
9656
|
* Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
|
|
9368
|
-
* (it encodes payloads: input/output/error). Bound as a
|
|
9369
|
-
* actually aggregates, so `store.runFacets?.()` still
|
|
9370
|
-
* of resolving to a silently wrong empty result.
|
|
9657
|
+
* (it encodes payloads: input/output, plus events/error under `extended` coverage). Bound as a
|
|
9658
|
+
* PROPERTY, and only when the inner store actually aggregates, so `store.runFacets?.()` still
|
|
9659
|
+
* reports absence through the wrapper instead of resolving to a silently wrong empty result.
|
|
9371
9660
|
*/
|
|
9372
9661
|
runFacets;
|
|
9373
9662
|
/**
|
|
@@ -9419,9 +9708,10 @@ var CodecStateStore = class {
|
|
|
9419
9708
|
updateSchedule;
|
|
9420
9709
|
deleteSchedule;
|
|
9421
9710
|
listSchedules;
|
|
9422
|
-
constructor(inner, codec) {
|
|
9711
|
+
constructor(inner, codec, options = {}) {
|
|
9423
9712
|
this.inner = inner;
|
|
9424
9713
|
this.codec = codec;
|
|
9714
|
+
this.extended = options.coverage === "extended";
|
|
9425
9715
|
if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
|
|
9426
9716
|
const deleteRuns = inner.deleteRuns;
|
|
9427
9717
|
if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
|
|
@@ -9479,32 +9769,52 @@ var CodecStateStore = class {
|
|
|
9479
9769
|
return v === void 0 ? void 0 : this.codec.decode(v);
|
|
9480
9770
|
}
|
|
9481
9771
|
encRun(run) {
|
|
9482
|
-
|
|
9772
|
+
const next = {
|
|
9483
9773
|
...run,
|
|
9484
9774
|
input: this.enc(run.input),
|
|
9485
9775
|
output: this.enc(run.output)
|
|
9486
9776
|
};
|
|
9777
|
+
if (this.extended && run.error !== void 0) {
|
|
9778
|
+
next.error = this.enc(run.error);
|
|
9779
|
+
}
|
|
9780
|
+
return next;
|
|
9487
9781
|
}
|
|
9488
9782
|
decRun(run) {
|
|
9489
|
-
|
|
9783
|
+
if (!run) return run;
|
|
9784
|
+
const next = {
|
|
9490
9785
|
...run,
|
|
9491
9786
|
input: this.dec(run.input),
|
|
9492
9787
|
output: this.dec(run.output)
|
|
9493
9788
|
};
|
|
9789
|
+
if (this.extended && run.error !== void 0) {
|
|
9790
|
+
next.error = this.dec(run.error);
|
|
9791
|
+
}
|
|
9792
|
+
return next;
|
|
9494
9793
|
}
|
|
9495
9794
|
encCp(cp) {
|
|
9496
|
-
|
|
9795
|
+
const next = {
|
|
9497
9796
|
...cp,
|
|
9498
9797
|
input: this.enc(cp.input),
|
|
9499
9798
|
output: this.enc(cp.output)
|
|
9500
9799
|
};
|
|
9800
|
+
if (this.extended) {
|
|
9801
|
+
if (cp.events !== void 0) next.events = this.enc(cp.events);
|
|
9802
|
+
if (cp.error !== void 0) next.error = this.enc(cp.error);
|
|
9803
|
+
}
|
|
9804
|
+
return next;
|
|
9501
9805
|
}
|
|
9502
9806
|
decCp(cp) {
|
|
9503
|
-
|
|
9807
|
+
if (!cp) return cp;
|
|
9808
|
+
const next = {
|
|
9504
9809
|
...cp,
|
|
9505
9810
|
input: this.dec(cp.input),
|
|
9506
9811
|
output: this.dec(cp.output)
|
|
9507
9812
|
};
|
|
9813
|
+
if (this.extended) {
|
|
9814
|
+
if (cp.events !== void 0) next.events = this.dec(cp.events);
|
|
9815
|
+
if (cp.error !== void 0) next.error = this.dec(cp.error);
|
|
9816
|
+
}
|
|
9817
|
+
return next;
|
|
9508
9818
|
}
|
|
9509
9819
|
encSpec(spec) {
|
|
9510
9820
|
return "input" in spec ? {
|
|
@@ -9543,6 +9853,9 @@ var CodecStateStore = class {
|
|
|
9543
9853
|
};
|
|
9544
9854
|
if ("input" in patch) next.input = this.enc(patch.input);
|
|
9545
9855
|
if ("output" in patch) next.output = this.enc(patch.output);
|
|
9856
|
+
if (this.extended && patch.error !== void 0) {
|
|
9857
|
+
next.error = this.enc(patch.error);
|
|
9858
|
+
}
|
|
9546
9859
|
return next;
|
|
9547
9860
|
}
|
|
9548
9861
|
async getRun(runId) {
|
|
@@ -9578,8 +9891,8 @@ var CodecStateStore = class {
|
|
|
9578
9891
|
tryLockRun(runId, owner, leaseUntilMs, nowMs) {
|
|
9579
9892
|
return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
|
|
9580
9893
|
}
|
|
9581
|
-
releaseRunLock(runId) {
|
|
9582
|
-
return this.inner.releaseRunLock(runId);
|
|
9894
|
+
releaseRunLock(runId, owner) {
|
|
9895
|
+
return owner === void 0 ? this.inner.releaseRunLock(runId) : this.inner.releaseRunLock(runId, owner);
|
|
9583
9896
|
}
|
|
9584
9897
|
renewRunLock(runId, owner, leaseUntilMs) {
|
|
9585
9898
|
return this.inner.renewRunLock(runId, owner, leaseUntilMs);
|
|
@@ -10087,9 +10400,9 @@ var InMemoryStateStore = class {
|
|
|
10087
10400
|
run.lockedUntil = leaseUntilMs;
|
|
10088
10401
|
return true;
|
|
10089
10402
|
}
|
|
10090
|
-
async releaseRunLock(runId) {
|
|
10403
|
+
async releaseRunLock(runId, owner) {
|
|
10091
10404
|
const run = this.runs.get(runId);
|
|
10092
|
-
if (run) {
|
|
10405
|
+
if (run && (owner === void 0 || run.lockedBy === owner)) {
|
|
10093
10406
|
run.lockedBy = void 0;
|
|
10094
10407
|
run.lockedUntil = void 0;
|
|
10095
10408
|
}
|
|
@@ -10439,6 +10752,7 @@ export {
|
|
|
10439
10752
|
ContinueAsNew,
|
|
10440
10753
|
DEFAULT_CONCURRENCY_STATUSES,
|
|
10441
10754
|
DEFAULT_PARTITION,
|
|
10755
|
+
DEFAULT_STALLED_AFTER_MS,
|
|
10442
10756
|
DURABLE_OPTIONS,
|
|
10443
10757
|
DURABLE_OPTIONS_CANONICAL,
|
|
10444
10758
|
DURABLE_STEP_CONFIG,
|
|
@@ -10462,6 +10776,7 @@ export {
|
|
|
10462
10776
|
NestedWorkflowCallError,
|
|
10463
10777
|
NonDeterminismError,
|
|
10464
10778
|
QueueController,
|
|
10779
|
+
RUN_HISTORY_FIXTURE_VERSION,
|
|
10465
10780
|
RUN_VALUE_FACET_LIMIT,
|
|
10466
10781
|
RUN_VALUE_FACET_SCAN,
|
|
10467
10782
|
RemoteStepError,
|
|
@@ -10479,6 +10794,7 @@ export {
|
|
|
10479
10794
|
ScheduleAlreadyExistsError,
|
|
10480
10795
|
ScheduleClient,
|
|
10481
10796
|
ScheduleNotFoundError,
|
|
10797
|
+
SchemaValidationError,
|
|
10482
10798
|
SignalTimeoutError,
|
|
10483
10799
|
SingletonQueueFullError,
|
|
10484
10800
|
TERMINAL_RUN_STATUSES,
|
|
@@ -10535,6 +10851,7 @@ export {
|
|
|
10535
10851
|
facetOrigin,
|
|
10536
10852
|
fanPageBounds,
|
|
10537
10853
|
fanPageEtag,
|
|
10854
|
+
findStalledRuns,
|
|
10538
10855
|
foldedCount,
|
|
10539
10856
|
handoffHandlerId,
|
|
10540
10857
|
handoffName,
|
|
@@ -10589,6 +10906,7 @@ export {
|
|
|
10589
10906
|
runStepHandler,
|
|
10590
10907
|
runValueFacetsFromRuns,
|
|
10591
10908
|
sanitizeQueueToken,
|
|
10909
|
+
scheduleRunIdAt,
|
|
10592
10910
|
scheduledRunId,
|
|
10593
10911
|
singletonAdmissionDenialPatch,
|
|
10594
10912
|
singletonAdmissionTags,
|