@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 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
- const registered = this.latest.get(name);
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)) {