@dudousxd/nestjs-durable-core 0.37.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 +146 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +146 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -1122,6 +1122,15 @@ interface WorkflowCtx {
|
|
|
1122
1122
|
random(): Promise<number>;
|
|
1123
1123
|
/** Deterministic UUID v4: recorded once, then replayed. Use instead of `crypto.randomUUID()`. */
|
|
1124
1124
|
uuid(): Promise<string>;
|
|
1125
|
+
/**
|
|
1126
|
+
* Merge `attrs` into THIS run's {@link WorkflowRun.searchAttributes} — the indexed metadata the
|
|
1127
|
+
* dashboard and {@link RunQuery} filter on. Shallow merge: keys you don't pass are kept. Durable +
|
|
1128
|
+
* exactly-once — recorded at this position on the first run and SKIPPED on replay, so it does one
|
|
1129
|
+
* write, not one per turn. Use this instead of injecting the {@link StateStore} to mutate the run
|
|
1130
|
+
* you're executing (`@Inject(state-store)` + `store.updateRun(ctx.runId, …)` becomes
|
|
1131
|
+
* `ctx.upsertSearchAttributes(…)`).
|
|
1132
|
+
*/
|
|
1133
|
+
upsertSearchAttributes(attrs: SearchAttributes): Promise<void>;
|
|
1125
1134
|
}
|
|
1126
1135
|
/** Result of executing or resuming a workflow run. */
|
|
1127
1136
|
interface RunResult {
|
|
@@ -1494,6 +1503,16 @@ declare class WorkflowEngine {
|
|
|
1494
1503
|
* Register a workflow version. Register multiple versions of the same name to keep in-flight
|
|
1495
1504
|
* runs working across a breaking change: old runs resume on the version they started on, new
|
|
1496
1505
|
* runs start on the newest registered version.
|
|
1506
|
+
*
|
|
1507
|
+
* GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
|
|
1508
|
+
* this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
|
|
1509
|
+
* inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
|
|
1510
|
+
* body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
|
|
1511
|
+
* ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
|
|
1512
|
+
* engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
|
|
1513
|
+
* engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
|
|
1514
|
+
* inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
|
|
1515
|
+
* step toward routing every run through a group with no local/remote flag (Phase 3).
|
|
1497
1516
|
*/
|
|
1498
1517
|
register(name: string, version: string, fn: WorkflowFn, opts?: {
|
|
1499
1518
|
tags?: string[] | undefined;
|
|
@@ -1502,7 +1521,22 @@ declare class WorkflowEngine {
|
|
|
1502
1521
|
validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
|
|
1503
1522
|
onEvent?: string[] | undefined;
|
|
1504
1523
|
eventBatch?: EventBatchConfig | undefined;
|
|
1524
|
+
/** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
|
|
1525
|
+
* Omit for the default inline fast path. See the method doc. */
|
|
1526
|
+
group?: string | undefined;
|
|
1527
|
+
/** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
|
|
1528
|
+
* in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
|
|
1529
|
+
* worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
|
|
1530
|
+
executor?: WorkflowExecutor | undefined;
|
|
1505
1531
|
}): void;
|
|
1532
|
+
/**
|
|
1533
|
+
* The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
|
|
1534
|
+
* own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
|
|
1535
|
+
* the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
|
|
1536
|
+
* it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
|
|
1537
|
+
* remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
|
|
1538
|
+
*/
|
|
1539
|
+
workflowBody(name: string, version: string): WorkflowFn | undefined;
|
|
1506
1540
|
/**
|
|
1507
1541
|
* Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
|
|
1508
1542
|
* as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
|
|
@@ -1566,6 +1600,49 @@ declare class WorkflowEngine {
|
|
|
1566
1600
|
/** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
|
|
1567
1601
|
getRun(runId: string): Promise<WorkflowRun | null>;
|
|
1568
1602
|
resume(runId: string): Promise<RunResult>;
|
|
1603
|
+
/**
|
|
1604
|
+
* The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
|
|
1605
|
+
* `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
|
|
1606
|
+
* flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
|
|
1607
|
+
* never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
|
|
1608
|
+
* Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
|
|
1609
|
+
* is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
|
|
1610
|
+
* no redundant registration per child name.
|
|
1611
|
+
*
|
|
1612
|
+
* RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
|
|
1613
|
+
* registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
|
|
1614
|
+
* registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
|
|
1615
|
+
* new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
|
|
1616
|
+
*/
|
|
1617
|
+
private findInheritedRegistration;
|
|
1618
|
+
/**
|
|
1619
|
+
* A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
|
|
1620
|
+
* group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
|
|
1621
|
+
* {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
|
|
1622
|
+
* is correct AND required — a second executor on the same group would race the worker for results.
|
|
1623
|
+
* Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
|
|
1624
|
+
* its identity stays its own.
|
|
1625
|
+
*/
|
|
1626
|
+
private synthesizeRemoteChild;
|
|
1627
|
+
/**
|
|
1628
|
+
* Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
|
|
1629
|
+
* registration (or `undefined` if there is none). The parent of an awaited child is found via its
|
|
1630
|
+
* live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
|
|
1631
|
+
* child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
|
|
1632
|
+
* cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
|
|
1633
|
+
* unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
|
|
1634
|
+
* unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
|
|
1635
|
+
* `visited` guards against a pathological cyclic id graph.
|
|
1636
|
+
*/
|
|
1637
|
+
private findRemoteAncestor;
|
|
1638
|
+
/**
|
|
1639
|
+
* The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
|
|
1640
|
+
* child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
|
|
1641
|
+
* and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
|
|
1642
|
+
* registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
|
|
1643
|
+
* waiter is consumed), but by then the child is terminal and no longer resolved.
|
|
1644
|
+
*/
|
|
1645
|
+
private findParentRunId;
|
|
1569
1646
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
1570
1647
|
private track;
|
|
1571
1648
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1122,6 +1122,15 @@ interface WorkflowCtx {
|
|
|
1122
1122
|
random(): Promise<number>;
|
|
1123
1123
|
/** Deterministic UUID v4: recorded once, then replayed. Use instead of `crypto.randomUUID()`. */
|
|
1124
1124
|
uuid(): Promise<string>;
|
|
1125
|
+
/**
|
|
1126
|
+
* Merge `attrs` into THIS run's {@link WorkflowRun.searchAttributes} — the indexed metadata the
|
|
1127
|
+
* dashboard and {@link RunQuery} filter on. Shallow merge: keys you don't pass are kept. Durable +
|
|
1128
|
+
* exactly-once — recorded at this position on the first run and SKIPPED on replay, so it does one
|
|
1129
|
+
* write, not one per turn. Use this instead of injecting the {@link StateStore} to mutate the run
|
|
1130
|
+
* you're executing (`@Inject(state-store)` + `store.updateRun(ctx.runId, …)` becomes
|
|
1131
|
+
* `ctx.upsertSearchAttributes(…)`).
|
|
1132
|
+
*/
|
|
1133
|
+
upsertSearchAttributes(attrs: SearchAttributes): Promise<void>;
|
|
1125
1134
|
}
|
|
1126
1135
|
/** Result of executing or resuming a workflow run. */
|
|
1127
1136
|
interface RunResult {
|
|
@@ -1494,6 +1503,16 @@ declare class WorkflowEngine {
|
|
|
1494
1503
|
* Register a workflow version. Register multiple versions of the same name to keep in-flight
|
|
1495
1504
|
* runs working across a breaking change: old runs resume on the version they started on, new
|
|
1496
1505
|
* runs start on the newest registered version.
|
|
1506
|
+
*
|
|
1507
|
+
* GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
|
|
1508
|
+
* this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
|
|
1509
|
+
* inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
|
|
1510
|
+
* body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
|
|
1511
|
+
* ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
|
|
1512
|
+
* engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
|
|
1513
|
+
* engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
|
|
1514
|
+
* inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
|
|
1515
|
+
* step toward routing every run through a group with no local/remote flag (Phase 3).
|
|
1497
1516
|
*/
|
|
1498
1517
|
register(name: string, version: string, fn: WorkflowFn, opts?: {
|
|
1499
1518
|
tags?: string[] | undefined;
|
|
@@ -1502,7 +1521,22 @@ declare class WorkflowEngine {
|
|
|
1502
1521
|
validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
|
|
1503
1522
|
onEvent?: string[] | undefined;
|
|
1504
1523
|
eventBatch?: EventBatchConfig | undefined;
|
|
1524
|
+
/** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
|
|
1525
|
+
* Omit for the default inline fast path. See the method doc. */
|
|
1526
|
+
group?: string | undefined;
|
|
1527
|
+
/** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
|
|
1528
|
+
* in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
|
|
1529
|
+
* worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
|
|
1530
|
+
executor?: WorkflowExecutor | undefined;
|
|
1505
1531
|
}): void;
|
|
1532
|
+
/**
|
|
1533
|
+
* The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
|
|
1534
|
+
* own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
|
|
1535
|
+
* the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
|
|
1536
|
+
* it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
|
|
1537
|
+
* remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
|
|
1538
|
+
*/
|
|
1539
|
+
workflowBody(name: string, version: string): WorkflowFn | undefined;
|
|
1506
1540
|
/**
|
|
1507
1541
|
* Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
|
|
1508
1542
|
* as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
|
|
@@ -1566,6 +1600,49 @@ declare class WorkflowEngine {
|
|
|
1566
1600
|
/** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
|
|
1567
1601
|
getRun(runId: string): Promise<WorkflowRun | null>;
|
|
1568
1602
|
resume(runId: string): Promise<RunResult>;
|
|
1603
|
+
/**
|
|
1604
|
+
* The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
|
|
1605
|
+
* `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
|
|
1606
|
+
* flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
|
|
1607
|
+
* never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
|
|
1608
|
+
* Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
|
|
1609
|
+
* is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
|
|
1610
|
+
* no redundant registration per child name.
|
|
1611
|
+
*
|
|
1612
|
+
* RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
|
|
1613
|
+
* registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
|
|
1614
|
+
* registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
|
|
1615
|
+
* new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
|
|
1616
|
+
*/
|
|
1617
|
+
private findInheritedRegistration;
|
|
1618
|
+
/**
|
|
1619
|
+
* A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
|
|
1620
|
+
* group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
|
|
1621
|
+
* {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
|
|
1622
|
+
* is correct AND required — a second executor on the same group would race the worker for results.
|
|
1623
|
+
* Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
|
|
1624
|
+
* its identity stays its own.
|
|
1625
|
+
*/
|
|
1626
|
+
private synthesizeRemoteChild;
|
|
1627
|
+
/**
|
|
1628
|
+
* Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
|
|
1629
|
+
* registration (or `undefined` if there is none). The parent of an awaited child is found via its
|
|
1630
|
+
* live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
|
|
1631
|
+
* child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
|
|
1632
|
+
* cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
|
|
1633
|
+
* unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
|
|
1634
|
+
* unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
|
|
1635
|
+
* `visited` guards against a pathological cyclic id graph.
|
|
1636
|
+
*/
|
|
1637
|
+
private findRemoteAncestor;
|
|
1638
|
+
/**
|
|
1639
|
+
* The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
|
|
1640
|
+
* child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
|
|
1641
|
+
* and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
|
|
1642
|
+
* registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
|
|
1643
|
+
* waiter is consumed), but by then the child is terminal and no longer resolved.
|
|
1644
|
+
*/
|
|
1645
|
+
private findParentRunId;
|
|
1569
1646
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
1570
1647
|
private track;
|
|
1571
1648
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1362,9 +1362,28 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
|
|
|
1362
1362
|
const now = /* @__PURE__ */ __name(() => step("now", async () => host.clock()), "now");
|
|
1363
1363
|
const random = /* @__PURE__ */ __name(() => step("random", async () => Math.random()), "random");
|
|
1364
1364
|
const uuid = /* @__PURE__ */ __name(() => step("uuid", async () => globalThis.crypto.randomUUID()), "uuid");
|
|
1365
|
+
const upsertSearchAttributes = /* @__PURE__ */ __name(async (attrs) => {
|
|
1366
|
+
const current = pos.next();
|
|
1367
|
+
const existing = await readCheckpoint(current);
|
|
1368
|
+
if (existing) {
|
|
1369
|
+
if (existing.name !== "searchAttributes") {
|
|
1370
|
+
throw new NonDeterminismError(runId, current, "searchAttributes", existing.name);
|
|
1371
|
+
}
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
await host.upsertSearchAttributes(runId, attrs);
|
|
1375
|
+
await writeCheckpoint(instantCheckpoint({
|
|
1376
|
+
runId,
|
|
1377
|
+
seq: current,
|
|
1378
|
+
name: "searchAttributes",
|
|
1379
|
+
kind: "local",
|
|
1380
|
+
output: attrs
|
|
1381
|
+
}));
|
|
1382
|
+
}, "upsertSearchAttributes");
|
|
1365
1383
|
return {
|
|
1366
1384
|
runId,
|
|
1367
1385
|
step,
|
|
1386
|
+
upsertSearchAttributes,
|
|
1368
1387
|
transaction,
|
|
1369
1388
|
callEntity,
|
|
1370
1389
|
signalEntity,
|
|
@@ -1607,8 +1626,21 @@ var WorkflowEngine = class {
|
|
|
1607
1626
|
* Register a workflow version. Register multiple versions of the same name to keep in-flight
|
|
1608
1627
|
* runs working across a breaking change: old runs resume on the version they started on, new
|
|
1609
1628
|
* runs start on the newest registered version.
|
|
1629
|
+
*
|
|
1630
|
+
* GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
|
|
1631
|
+
* this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
|
|
1632
|
+
* inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
|
|
1633
|
+
* body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
|
|
1634
|
+
* ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
|
|
1635
|
+
* engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
|
|
1636
|
+
* engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
|
|
1637
|
+
* inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
|
|
1638
|
+
* step toward routing every run through a group with no local/remote flag (Phase 3).
|
|
1610
1639
|
*/
|
|
1611
1640
|
register(name, version, fn, opts) {
|
|
1641
|
+
if (opts?.group == null !== (opts?.executor == null)) {
|
|
1642
|
+
throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
|
|
1643
|
+
}
|
|
1612
1644
|
const registered = {
|
|
1613
1645
|
name,
|
|
1614
1646
|
version,
|
|
@@ -1618,8 +1650,17 @@ var WorkflowEngine = class {
|
|
|
1618
1650
|
executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
|
|
1619
1651
|
validateInput: opts?.validateInput,
|
|
1620
1652
|
onEvent: opts?.onEvent,
|
|
1621
|
-
eventBatch: opts?.eventBatch
|
|
1653
|
+
eventBatch: opts?.eventBatch,
|
|
1654
|
+
// A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
|
|
1655
|
+
// it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
|
|
1656
|
+
hasBody: true
|
|
1622
1657
|
};
|
|
1658
|
+
if (opts?.group != null && opts?.executor != null) {
|
|
1659
|
+
registered.remote = {
|
|
1660
|
+
group: opts.group,
|
|
1661
|
+
executor: opts.executor
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1623
1664
|
this.workflows.set(versionKey(name, version), registered);
|
|
1624
1665
|
const current = this.latest.get(name);
|
|
1625
1666
|
if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
|
|
@@ -1630,6 +1671,17 @@ var WorkflowEngine = class {
|
|
|
1630
1671
|
}
|
|
1631
1672
|
}
|
|
1632
1673
|
/**
|
|
1674
|
+
* The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
|
|
1675
|
+
* own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
|
|
1676
|
+
* the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
|
|
1677
|
+
* it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
|
|
1678
|
+
* remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
|
|
1679
|
+
*/
|
|
1680
|
+
workflowBody(name, version) {
|
|
1681
|
+
const reg = this.workflows.get(versionKey(name, version));
|
|
1682
|
+
return reg?.hasBody ? reg.fn : void 0;
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1633
1685
|
* Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
|
|
1634
1686
|
* as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
|
|
1635
1687
|
* the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
|
|
@@ -1755,7 +1807,11 @@ var WorkflowEngine = class {
|
|
|
1755
1807
|
}
|
|
1756
1808
|
async start(workflow, input, runId, opts) {
|
|
1757
1809
|
const name = workflowName(workflow);
|
|
1758
|
-
|
|
1810
|
+
let registered = this.latest.get(name);
|
|
1811
|
+
if (!registered) {
|
|
1812
|
+
const ancestor = await this.findRemoteAncestor(runId);
|
|
1813
|
+
if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
|
|
1814
|
+
}
|
|
1759
1815
|
if (!registered) throw new Error(`workflow ${name} is not registered`);
|
|
1760
1816
|
await registered.validateInput?.(input);
|
|
1761
1817
|
const prior = await this.store.getRun(runId);
|
|
@@ -1816,12 +1872,87 @@ var WorkflowEngine = class {
|
|
|
1816
1872
|
error: run.error
|
|
1817
1873
|
};
|
|
1818
1874
|
}
|
|
1819
|
-
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
1875
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
|
|
1820
1876
|
if (!registered) {
|
|
1821
1877
|
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)`);
|
|
1822
1878
|
}
|
|
1823
1879
|
return this.track(this.execute(run, registered.fn));
|
|
1824
1880
|
}
|
|
1881
|
+
/**
|
|
1882
|
+
* The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
|
|
1883
|
+
* `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
|
|
1884
|
+
* flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
|
|
1885
|
+
* never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
|
|
1886
|
+
* Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
|
|
1887
|
+
* is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
|
|
1888
|
+
* no redundant registration per child name.
|
|
1889
|
+
*
|
|
1890
|
+
* RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
|
|
1891
|
+
* registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
|
|
1892
|
+
* registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
|
|
1893
|
+
* new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
|
|
1894
|
+
*/
|
|
1895
|
+
async findInheritedRegistration(run) {
|
|
1896
|
+
const ancestor = await this.findRemoteAncestor(run.id);
|
|
1897
|
+
if (!ancestor) return void 0;
|
|
1898
|
+
return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
|
|
1902
|
+
* group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
|
|
1903
|
+
* {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
|
|
1904
|
+
* is correct AND required — a second executor on the same group would race the worker for results.
|
|
1905
|
+
* Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
|
|
1906
|
+
* its identity stays its own.
|
|
1907
|
+
*/
|
|
1908
|
+
synthesizeRemoteChild(name, version, ancestor) {
|
|
1909
|
+
const remote = ancestor.remote;
|
|
1910
|
+
if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
|
|
1911
|
+
return {
|
|
1912
|
+
name,
|
|
1913
|
+
version,
|
|
1914
|
+
fn: /* @__PURE__ */ __name(() => {
|
|
1915
|
+
throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
|
|
1916
|
+
}, "fn"),
|
|
1917
|
+
remote
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
|
|
1922
|
+
* registration (or `undefined` if there is none). The parent of an awaited child is found via its
|
|
1923
|
+
* live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
|
|
1924
|
+
* child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
|
|
1925
|
+
* cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
|
|
1926
|
+
* unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
|
|
1927
|
+
* unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
|
|
1928
|
+
* `visited` guards against a pathological cyclic id graph.
|
|
1929
|
+
*/
|
|
1930
|
+
async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
|
|
1931
|
+
if (visited.has(childRunId)) return void 0;
|
|
1932
|
+
visited.add(childRunId);
|
|
1933
|
+
const parentRunId = await this.findParentRunId(childRunId);
|
|
1934
|
+
if (!parentRunId) return void 0;
|
|
1935
|
+
const parent = await this.store.getRun(parentRunId);
|
|
1936
|
+
if (!parent) return void 0;
|
|
1937
|
+
const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
|
|
1938
|
+
if (parentReg?.remote) return parentReg;
|
|
1939
|
+
if (parentReg) return void 0;
|
|
1940
|
+
return this.findRemoteAncestor(parent.id, visited);
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
|
|
1944
|
+
* child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
|
|
1945
|
+
* and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
|
|
1946
|
+
* registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
|
|
1947
|
+
* waiter is consumed), but by then the child is terminal and no longer resolved.
|
|
1948
|
+
*/
|
|
1949
|
+
async findParentRunId(childRunId) {
|
|
1950
|
+
const token = `child:${childRunId}`;
|
|
1951
|
+
for (const waiter of await this.store.listSignalWaiters("child:")) {
|
|
1952
|
+
if (waiter.token === token) return waiter.runId;
|
|
1953
|
+
}
|
|
1954
|
+
return void 0;
|
|
1955
|
+
}
|
|
1825
1956
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
1826
1957
|
track(p) {
|
|
1827
1958
|
this.inflight.add(p);
|
|
@@ -2511,7 +2642,7 @@ var WorkflowEngine = class {
|
|
|
2511
2642
|
});
|
|
2512
2643
|
}
|
|
2513
2644
|
async execute(run, fn) {
|
|
2514
|
-
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
2645
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
|
|
2515
2646
|
const lockNow = this.clock();
|
|
2516
2647
|
if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
|
|
2517
2648
|
if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
|
|
@@ -3093,6 +3224,17 @@ var WorkflowEngine = class {
|
|
|
3093
3224
|
priority
|
|
3094
3225
|
}).catch(() => void 0));
|
|
3095
3226
|
}, "startChild"),
|
|
3227
|
+
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
3228
|
+
upsertSearchAttributes: /* @__PURE__ */ __name(async (runId, attrs) => {
|
|
3229
|
+
const run = await this.store.getRun(runId);
|
|
3230
|
+
await this.store.updateRun(runId, {
|
|
3231
|
+
searchAttributes: {
|
|
3232
|
+
...run?.searchAttributes ?? {},
|
|
3233
|
+
...attrs
|
|
3234
|
+
},
|
|
3235
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3236
|
+
});
|
|
3237
|
+
}, "upsertSearchAttributes"),
|
|
3096
3238
|
signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
|
|
3097
3239
|
queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
|
|
3098
3240
|
}, "signalEntity"),
|