@dudousxd/nestjs-durable-core 0.38.0 → 0.39.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 +116 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +116 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1698,8 +1698,21 @@ var WorkflowEngine = class {
|
|
|
1698
1698
|
* Register a workflow version. Register multiple versions of the same name to keep in-flight
|
|
1699
1699
|
* runs working across a breaking change: old runs resume on the version they started on, new
|
|
1700
1700
|
* runs start on the newest registered version.
|
|
1701
|
+
*
|
|
1702
|
+
* GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
|
|
1703
|
+
* this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
|
|
1704
|
+
* inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
|
|
1705
|
+
* body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
|
|
1706
|
+
* ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
|
|
1707
|
+
* engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
|
|
1708
|
+
* engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
|
|
1709
|
+
* inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
|
|
1710
|
+
* step toward routing every run through a group with no local/remote flag (Phase 3).
|
|
1701
1711
|
*/
|
|
1702
1712
|
register(name, version, fn, opts) {
|
|
1713
|
+
if (opts?.group == null !== (opts?.executor == null)) {
|
|
1714
|
+
throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
|
|
1715
|
+
}
|
|
1703
1716
|
const registered = {
|
|
1704
1717
|
name,
|
|
1705
1718
|
version,
|
|
@@ -1709,8 +1722,17 @@ var WorkflowEngine = class {
|
|
|
1709
1722
|
executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
|
|
1710
1723
|
validateInput: opts?.validateInput,
|
|
1711
1724
|
onEvent: opts?.onEvent,
|
|
1712
|
-
eventBatch: opts?.eventBatch
|
|
1725
|
+
eventBatch: opts?.eventBatch,
|
|
1726
|
+
// A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
|
|
1727
|
+
// it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
|
|
1728
|
+
hasBody: true
|
|
1713
1729
|
};
|
|
1730
|
+
if (opts?.group != null && opts?.executor != null) {
|
|
1731
|
+
registered.remote = {
|
|
1732
|
+
group: opts.group,
|
|
1733
|
+
executor: opts.executor
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1714
1736
|
this.workflows.set(versionKey(name, version), registered);
|
|
1715
1737
|
const current = this.latest.get(name);
|
|
1716
1738
|
if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
|
|
@@ -1721,6 +1743,17 @@ var WorkflowEngine = class {
|
|
|
1721
1743
|
}
|
|
1722
1744
|
}
|
|
1723
1745
|
/**
|
|
1746
|
+
* The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
|
|
1747
|
+
* own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
|
|
1748
|
+
* the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
|
|
1749
|
+
* it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
|
|
1750
|
+
* remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
|
|
1751
|
+
*/
|
|
1752
|
+
workflowBody(name, version) {
|
|
1753
|
+
const reg = this.workflows.get(versionKey(name, version));
|
|
1754
|
+
return reg?.hasBody ? reg.fn : void 0;
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1724
1757
|
* Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
|
|
1725
1758
|
* as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
|
|
1726
1759
|
* the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
|
|
@@ -1846,7 +1879,11 @@ var WorkflowEngine = class {
|
|
|
1846
1879
|
}
|
|
1847
1880
|
async start(workflow, input, runId, opts) {
|
|
1848
1881
|
const name = workflowName(workflow);
|
|
1849
|
-
|
|
1882
|
+
let registered = this.latest.get(name);
|
|
1883
|
+
if (!registered) {
|
|
1884
|
+
const ancestor = await this.findRemoteAncestor(runId);
|
|
1885
|
+
if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
|
|
1886
|
+
}
|
|
1850
1887
|
if (!registered) throw new Error(`workflow ${name} is not registered`);
|
|
1851
1888
|
await registered.validateInput?.(input);
|
|
1852
1889
|
const prior = await this.store.getRun(runId);
|
|
@@ -1907,12 +1944,87 @@ var WorkflowEngine = class {
|
|
|
1907
1944
|
error: run.error
|
|
1908
1945
|
};
|
|
1909
1946
|
}
|
|
1910
|
-
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
1947
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
|
|
1911
1948
|
if (!registered) {
|
|
1912
1949
|
throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
|
|
1913
1950
|
}
|
|
1914
1951
|
return this.track(this.execute(run, registered.fn));
|
|
1915
1952
|
}
|
|
1953
|
+
/**
|
|
1954
|
+
* The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
|
|
1955
|
+
* `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
|
|
1956
|
+
* flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
|
|
1957
|
+
* never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
|
|
1958
|
+
* Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
|
|
1959
|
+
* is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
|
|
1960
|
+
* no redundant registration per child name.
|
|
1961
|
+
*
|
|
1962
|
+
* RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
|
|
1963
|
+
* registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
|
|
1964
|
+
* registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
|
|
1965
|
+
* new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
|
|
1966
|
+
*/
|
|
1967
|
+
async findInheritedRegistration(run) {
|
|
1968
|
+
const ancestor = await this.findRemoteAncestor(run.id);
|
|
1969
|
+
if (!ancestor) return void 0;
|
|
1970
|
+
return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
|
|
1974
|
+
* group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
|
|
1975
|
+
* {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
|
|
1976
|
+
* is correct AND required — a second executor on the same group would race the worker for results.
|
|
1977
|
+
* Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
|
|
1978
|
+
* its identity stays its own.
|
|
1979
|
+
*/
|
|
1980
|
+
synthesizeRemoteChild(name, version, ancestor) {
|
|
1981
|
+
const remote = ancestor.remote;
|
|
1982
|
+
if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
|
|
1983
|
+
return {
|
|
1984
|
+
name,
|
|
1985
|
+
version,
|
|
1986
|
+
fn: /* @__PURE__ */ __name(() => {
|
|
1987
|
+
throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
|
|
1988
|
+
}, "fn"),
|
|
1989
|
+
remote
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
|
|
1994
|
+
* registration (or `undefined` if there is none). The parent of an awaited child is found via its
|
|
1995
|
+
* live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
|
|
1996
|
+
* child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
|
|
1997
|
+
* cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
|
|
1998
|
+
* unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
|
|
1999
|
+
* unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
|
|
2000
|
+
* `visited` guards against a pathological cyclic id graph.
|
|
2001
|
+
*/
|
|
2002
|
+
async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
|
|
2003
|
+
if (visited.has(childRunId)) return void 0;
|
|
2004
|
+
visited.add(childRunId);
|
|
2005
|
+
const parentRunId = await this.findParentRunId(childRunId);
|
|
2006
|
+
if (!parentRunId) return void 0;
|
|
2007
|
+
const parent = await this.store.getRun(parentRunId);
|
|
2008
|
+
if (!parent) return void 0;
|
|
2009
|
+
const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
|
|
2010
|
+
if (parentReg?.remote) return parentReg;
|
|
2011
|
+
if (parentReg) return void 0;
|
|
2012
|
+
return this.findRemoteAncestor(parent.id, visited);
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
|
|
2016
|
+
* child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
|
|
2017
|
+
* and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
|
|
2018
|
+
* registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
|
|
2019
|
+
* waiter is consumed), but by then the child is terminal and no longer resolved.
|
|
2020
|
+
*/
|
|
2021
|
+
async findParentRunId(childRunId) {
|
|
2022
|
+
const token = `child:${childRunId}`;
|
|
2023
|
+
for (const waiter of await this.store.listSignalWaiters("child:")) {
|
|
2024
|
+
if (waiter.token === token) return waiter.runId;
|
|
2025
|
+
}
|
|
2026
|
+
return void 0;
|
|
2027
|
+
}
|
|
1916
2028
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
1917
2029
|
track(p) {
|
|
1918
2030
|
this.inflight.add(p);
|
|
@@ -2602,7 +2714,7 @@ var WorkflowEngine = class {
|
|
|
2602
2714
|
});
|
|
2603
2715
|
}
|
|
2604
2716
|
async execute(run, fn) {
|
|
2605
|
-
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
2717
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
|
|
2606
2718
|
const lockNow = this.clock();
|
|
2607
2719
|
if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
|
|
2608
2720
|
if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
|