@adonis-agora/durable 0.9.0 → 0.10.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/CHANGELOG.md +35 -0
- package/dist/providers/dashboard_provider.d.ts +1 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +5 -1
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts +7 -0
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +15 -0
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +2 -2
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +2 -2
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/engine.d.ts +64 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +223 -9
- package/dist/src/engine.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +16 -5
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/resolve-lucid-db.d.ts +23 -0
- package/dist/src/resolve-lucid-db.d.ts.map +1 -0
- package/dist/src/resolve-lucid-db.js +16 -0
- package/dist/src/resolve-lucid-db.js.map +1 -0
- package/dist/src/stores/factory.d.ts.map +1 -1
- package/dist/src/stores/factory.js +3 -2
- package/dist/src/stores/factory.js.map +1 -1
- package/dist/src/stores/lucid-schema.d.ts +6 -2
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +14 -9
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.js +3 -3
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +3 -2
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts +9 -0
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +75 -27
- package/dist/src/workflow-ctx.js.map +1 -1
- package/package.json +1 -1
package/dist/src/engine.js
CHANGED
|
@@ -67,6 +67,9 @@ export class WorkflowEngine {
|
|
|
67
67
|
namespace;
|
|
68
68
|
leaseMs;
|
|
69
69
|
maxRecoveryAttempts;
|
|
70
|
+
reconcileMs;
|
|
71
|
+
remoteRedispatchMs;
|
|
72
|
+
remoteRedispatchMax;
|
|
70
73
|
webhookUrl;
|
|
71
74
|
traceparent;
|
|
72
75
|
context;
|
|
@@ -134,6 +137,11 @@ export class WorkflowEngine {
|
|
|
134
137
|
this.pool.useNamespace(this.namespace);
|
|
135
138
|
this.leaseMs = deps.leaseMs ?? 30_000;
|
|
136
139
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
140
|
+
// Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
|
|
141
|
+
this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
|
|
142
|
+
// Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
|
|
143
|
+
this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
|
|
144
|
+
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
137
145
|
this.webhookUrl = deps.webhookUrl;
|
|
138
146
|
this.traceparent = deps.traceparent;
|
|
139
147
|
this.context = deps.context;
|
|
@@ -695,15 +703,124 @@ export class WorkflowEngine {
|
|
|
695
703
|
* inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
|
|
696
704
|
* the HTTP request on workflow execution.
|
|
697
705
|
*/
|
|
698
|
-
async requeue(runId) {
|
|
706
|
+
async requeue(runId, _seen) {
|
|
707
|
+
const seen = _seen ?? new Set();
|
|
708
|
+
if (seen.has(runId))
|
|
709
|
+
return null;
|
|
710
|
+
seen.add(runId);
|
|
699
711
|
const run = await this.store.getRun(runId);
|
|
700
712
|
if (!run)
|
|
701
713
|
return null;
|
|
714
|
+
// A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
|
|
715
|
+
// failed checkpoint rethrows, and an awaited child's failure completion rethrows through
|
|
716
|
+
// unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
|
|
717
|
+
// milliseconds. Reset the failure state first so replay RE-EXECUTES the failed parts:
|
|
718
|
+
// - a `failed` checkpoint becomes retryable-now (attempts 0, wake immediately, `retryable`
|
|
719
|
+
// forced back on): the durable-retry machinery re-dispatches it as a fresh first attempt;
|
|
720
|
+
// - an awaited-child `signal:child:` checkpoint holding a FAILURE completion returns to its
|
|
721
|
+
// live `running` placeholder, so replay re-registers the `child:<id>` waiter — a separately
|
|
722
|
+
// retried child's completion (already buffered, or still to come) then resumes this run.
|
|
723
|
+
// Retry child and parent in either order; signal buffering makes it converge.
|
|
724
|
+
if (run.status === 'failed' || run.status === 'dead') {
|
|
725
|
+
const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
|
|
726
|
+
for (const cp of await this.store.listCheckpoints(runId)) {
|
|
727
|
+
if (cp.status === 'failed') {
|
|
728
|
+
await this.store.saveCheckpoint({
|
|
729
|
+
...cp,
|
|
730
|
+
attempts: 0,
|
|
731
|
+
wakeAt: this.clock(),
|
|
732
|
+
...(cp.error ? { error: { ...cp.error, retryable: true } } : {}),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
else if (cp.kind === 'signal' &&
|
|
736
|
+
cp.name.startsWith('signal:child:') &&
|
|
737
|
+
cp.status === 'completed' &&
|
|
738
|
+
isFailureCompletion(cp.output)) {
|
|
739
|
+
await this.store.saveCheckpoint({ ...cp, status: 'running', output: undefined });
|
|
740
|
+
// CASCADE: retrying only the parent is useless in a live engine — replay re-registers
|
|
741
|
+
// the `child:<id>` waiter and the reconciler re-delivers the child's still-FAILED
|
|
742
|
+
// terminal state within seconds, re-failing the parent with the same error. Requeue the
|
|
743
|
+
// failed child too so the pair converges: whichever finishes the handshake last (waiter
|
|
744
|
+
// registered vs completion buffered) resumes the parent.
|
|
745
|
+
//
|
|
746
|
+
// Exception: a SUCCESS already buffered on this token (a completed `~retry~` fix of the
|
|
747
|
+
// child — see notifyParent's origin delivery) is the outcome the replay will consume, so
|
|
748
|
+
// re-running the failed origin would be pure waste. Peek = take + re-buffer.
|
|
749
|
+
const childId = cp.name.slice('signal:child:'.length);
|
|
750
|
+
// Drain-and-restore keeps relative order (a partial take+re-buffer would rotate it).
|
|
751
|
+
const parked = [];
|
|
752
|
+
for (;;) {
|
|
753
|
+
const buffered = await this.store.takeBufferedSignal(`child:${childId}`);
|
|
754
|
+
if (!buffered)
|
|
755
|
+
break;
|
|
756
|
+
parked.push(buffered.payload);
|
|
757
|
+
}
|
|
758
|
+
for (const payload of parked)
|
|
759
|
+
await this.store.bufferSignal(`child:${childId}`, payload);
|
|
760
|
+
const bufferedSuccess = parked.some((payload) => typeof payload === 'object' &&
|
|
761
|
+
payload !== null &&
|
|
762
|
+
payload.ok === true);
|
|
763
|
+
if (!bufferedSuccess) {
|
|
764
|
+
const child = await this.store.getRun(childId);
|
|
765
|
+
if (child && (child.status === 'failed' || child.status === 'dead')) {
|
|
766
|
+
await this.requeue(childId, seen);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
702
772
|
await this.store.releaseRunLock(runId);
|
|
703
|
-
|
|
773
|
+
// Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
|
|
774
|
+
// run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
|
|
775
|
+
await this.store.updateRun(runId, {
|
|
776
|
+
status: 'pending',
|
|
777
|
+
error: undefined,
|
|
778
|
+
updatedAt: new Date(),
|
|
779
|
+
});
|
|
704
780
|
await this.runDispatcher.dispatch(runId);
|
|
705
781
|
return { runId, status: 'pending' };
|
|
706
782
|
}
|
|
783
|
+
/**
|
|
784
|
+
* Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
|
|
785
|
+
* a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
|
|
786
|
+
* the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
|
|
787
|
+
* step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
|
|
788
|
+
* and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
|
|
789
|
+
* (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
|
|
790
|
+
* only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
|
|
791
|
+
* run's current status (with the count re-dispatched), or null if the run is unknown.
|
|
792
|
+
*/
|
|
793
|
+
async redispatchPending(runId) {
|
|
794
|
+
const run = await this.store.getRun(runId);
|
|
795
|
+
if (!run)
|
|
796
|
+
return null;
|
|
797
|
+
const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === 'remote' && cp.status === 'pending');
|
|
798
|
+
let redispatched = 0;
|
|
799
|
+
for (const cp of pending) {
|
|
800
|
+
if (!cp.stepId)
|
|
801
|
+
continue;
|
|
802
|
+
const reAttempt = cp.attempts + 1;
|
|
803
|
+
const reEnqueuedAt = new Date();
|
|
804
|
+
await this.store.saveCheckpoint({
|
|
805
|
+
...cp,
|
|
806
|
+
attempts: reAttempt,
|
|
807
|
+
enqueuedAt: reEnqueuedAt,
|
|
808
|
+
startedAt: reEnqueuedAt,
|
|
809
|
+
finishedAt: reEnqueuedAt,
|
|
810
|
+
});
|
|
811
|
+
await this.dispatchRemoteTask({
|
|
812
|
+
runId,
|
|
813
|
+
seq: cp.seq,
|
|
814
|
+
name: cp.name,
|
|
815
|
+
stepId: cp.stepId,
|
|
816
|
+
group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
|
|
817
|
+
input: cp.input,
|
|
818
|
+
attempt: reAttempt,
|
|
819
|
+
});
|
|
820
|
+
redispatched += 1;
|
|
821
|
+
}
|
|
822
|
+
return { runId, status: run.status, redispatched };
|
|
823
|
+
}
|
|
707
824
|
/**
|
|
708
825
|
* **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
|
|
709
826
|
* fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
|
|
@@ -982,6 +1099,15 @@ export class WorkflowEngine {
|
|
|
982
1099
|
*/
|
|
983
1100
|
notifyParent(runId, completion) {
|
|
984
1101
|
void this.signal(`child:${runId}`, completion).catch(() => undefined);
|
|
1102
|
+
// A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
|
|
1103
|
+
// origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
|
|
1104
|
+
// parent that failed on that child and is retried later consumes this success (buffered or
|
|
1105
|
+
// live) instead of waiting on a child nobody re-runs. Failures stay retry-only — a failed
|
|
1106
|
+
// fix attempt must not poison the origin's token.
|
|
1107
|
+
const at = runId.lastIndexOf('~retry~');
|
|
1108
|
+
if (at !== -1 && completion.ok) {
|
|
1109
|
+
void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => undefined);
|
|
1110
|
+
}
|
|
985
1111
|
}
|
|
986
1112
|
/**
|
|
987
1113
|
* Deferred child start shared by the in-process ctx host and the remote `startChild` command.
|
|
@@ -1393,7 +1519,11 @@ export class WorkflowEngine {
|
|
|
1393
1519
|
if (latest?.status === 'cancelled') {
|
|
1394
1520
|
return { runId: run.id, status: 'cancelled', error: latest.error };
|
|
1395
1521
|
}
|
|
1396
|
-
await this.store.updateRun(run.id, {
|
|
1522
|
+
await this.store.updateRun(run.id, {
|
|
1523
|
+
status: 'suspended',
|
|
1524
|
+
wakeAt: this.reconcileWakeAt(outcome.wakeAt),
|
|
1525
|
+
updatedAt,
|
|
1526
|
+
});
|
|
1397
1527
|
this.emit({
|
|
1398
1528
|
type: 'run.suspended',
|
|
1399
1529
|
runId: run.id,
|
|
@@ -1402,6 +1532,17 @@ export class WorkflowEngine {
|
|
|
1402
1532
|
});
|
|
1403
1533
|
return { runId: run.id, status: 'suspended' };
|
|
1404
1534
|
}
|
|
1535
|
+
/**
|
|
1536
|
+
* A wakeAt to persist on a suspend: the natural timer if the turn produced one (a `ctx.sleep`, a
|
|
1537
|
+
* remote-step deadline), else the {@link reconcileMs} fallback so an event-waiting run (child/signal/
|
|
1538
|
+
* timeout-less remote step) can't be orphaned forever if its wake is lost. `undefined` only when the
|
|
1539
|
+
* fallback is disabled (`reconcileMs: 0`). See {@link WorkflowEngineDeps.reconcileMs}.
|
|
1540
|
+
*/
|
|
1541
|
+
reconcileWakeAt(wakeAt) {
|
|
1542
|
+
if (wakeAt != null)
|
|
1543
|
+
return wakeAt;
|
|
1544
|
+
return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
|
|
1545
|
+
}
|
|
1405
1546
|
async runRemoteExecution(run, registered) {
|
|
1406
1547
|
const remote = registered.remote;
|
|
1407
1548
|
if (run.status === 'pending') {
|
|
@@ -1899,6 +2040,12 @@ export class WorkflowEngine {
|
|
|
1899
2040
|
startChild: (workflow, input, id, priority) => {
|
|
1900
2041
|
this.startChildDeferred(workflow, input, id, { priority });
|
|
1901
2042
|
},
|
|
2043
|
+
// Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
|
|
2044
|
+
// idempotent on a terminal/cancelled run (returns its existing status without side effects), so
|
|
2045
|
+
// no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
|
|
2046
|
+
cancelChild: (childId) => {
|
|
2047
|
+
queueMicrotask(() => void this.cancel(childId).catch(() => undefined));
|
|
2048
|
+
},
|
|
1902
2049
|
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
1903
2050
|
upsertSearchAttributes: async (runId, attrs) => {
|
|
1904
2051
|
const run = await this.store.getRun(runId);
|
|
@@ -1995,8 +2142,57 @@ export class WorkflowEngine {
|
|
|
1995
2142
|
// pod can scale down or crash mid-step without losing the run or re-running completed work.
|
|
1996
2143
|
if (step.timeoutMs)
|
|
1997
2144
|
return this.callRemoteInMemory(runId, seq, step, input, transport);
|
|
1998
|
-
if (existing?.status === 'pending')
|
|
1999
|
-
|
|
2145
|
+
if (existing?.status === 'pending') {
|
|
2146
|
+
// Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
|
|
2147
|
+
// (worker crashed with no result, or the transport dropped the job) would hang here forever — a
|
|
2148
|
+
// reconcile re-drive replays straight back to this guard. Opt-in self-heal: once the step has been
|
|
2149
|
+
// pending longer than `remoteRedispatchMs`, re-dispatch the same stepId (the idempotent step
|
|
2150
|
+
// re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
|
|
2151
|
+
// fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
|
|
2152
|
+
if (this.remoteRedispatchMs == null)
|
|
2153
|
+
throw new WorkflowSuspended();
|
|
2154
|
+
// Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
|
|
2155
|
+
// stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
|
|
2156
|
+
// becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
|
|
2157
|
+
if (existing.wakeAt == null) {
|
|
2158
|
+
const wakeAt = this.clock() + this.remoteRedispatchMs;
|
|
2159
|
+
await this.store.saveCheckpoint({ ...existing, wakeAt });
|
|
2160
|
+
throw new WorkflowSuspended(wakeAt);
|
|
2161
|
+
}
|
|
2162
|
+
if (this.clock() < existing.wakeAt)
|
|
2163
|
+
throw new WorkflowSuspended(existing.wakeAt);
|
|
2164
|
+
// Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
|
|
2165
|
+
// `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
|
|
2166
|
+
if (existing.attempts >= this.remoteRedispatchMax) {
|
|
2167
|
+
throw new RemoteStepError({
|
|
2168
|
+
message: `remote step "${step.name}" lost — no result after ${existing.attempts} re-dispatch(es)`,
|
|
2169
|
+
code: 'remote_step_lost',
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
const reAttempt = existing.attempts + 1;
|
|
2173
|
+
const nextDeadline = this.clock() + this.remoteRedispatchMs;
|
|
2174
|
+
const reEnqueuedAt = new Date();
|
|
2175
|
+
await this.store.saveCheckpoint({
|
|
2176
|
+
...existing,
|
|
2177
|
+
attempts: reAttempt,
|
|
2178
|
+
wakeAt: nextDeadline,
|
|
2179
|
+
enqueuedAt: reEnqueuedAt,
|
|
2180
|
+
startedAt: reEnqueuedAt,
|
|
2181
|
+
finishedAt: reEnqueuedAt,
|
|
2182
|
+
});
|
|
2183
|
+
await this.dispatchRemoteTask({
|
|
2184
|
+
runId,
|
|
2185
|
+
seq,
|
|
2186
|
+
name: step.name,
|
|
2187
|
+
stepId: existing.stepId ?? stepId(runId, seq),
|
|
2188
|
+
group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
|
|
2189
|
+
input: existing.input,
|
|
2190
|
+
priority: admission?.priority,
|
|
2191
|
+
attempt: reAttempt,
|
|
2192
|
+
transport,
|
|
2193
|
+
});
|
|
2194
|
+
throw new WorkflowSuspended(nextDeadline);
|
|
2195
|
+
}
|
|
2000
2196
|
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|
|
2001
2197
|
// unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
|
|
2002
2198
|
// The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
|
|
@@ -2063,21 +2259,39 @@ export class WorkflowEngine {
|
|
|
2063
2259
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
2064
2260
|
finishedAt: enqueuedAt,
|
|
2065
2261
|
});
|
|
2066
|
-
await this.
|
|
2262
|
+
await this.dispatchRemoteTask({
|
|
2067
2263
|
runId,
|
|
2068
2264
|
seq,
|
|
2069
2265
|
name: step.name,
|
|
2070
2266
|
stepId: id,
|
|
2071
2267
|
group: token,
|
|
2072
2268
|
input: validInput,
|
|
2073
|
-
traceparent: this.traceparent?.(),
|
|
2074
|
-
context: this.context?.(),
|
|
2075
2269
|
priority: admission?.priority,
|
|
2076
2270
|
attempt,
|
|
2077
|
-
|
|
2271
|
+
transport,
|
|
2272
|
+
});
|
|
2078
2273
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
2079
2274
|
throw new WorkflowSuspended();
|
|
2080
2275
|
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
2278
|
+
* dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
|
|
2279
|
+
* {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
|
|
2280
|
+
*/
|
|
2281
|
+
dispatchRemoteTask(task) {
|
|
2282
|
+
return this.pool.dispatch({
|
|
2283
|
+
runId: task.runId,
|
|
2284
|
+
seq: task.seq,
|
|
2285
|
+
name: task.name,
|
|
2286
|
+
stepId: task.stepId,
|
|
2287
|
+
group: task.group,
|
|
2288
|
+
input: task.input,
|
|
2289
|
+
traceparent: this.traceparent?.(),
|
|
2290
|
+
context: this.context?.(),
|
|
2291
|
+
priority: task.priority,
|
|
2292
|
+
attempt: task.attempt,
|
|
2293
|
+
}, task.transport);
|
|
2294
|
+
}
|
|
2081
2295
|
/**
|
|
2082
2296
|
* Complete a durable remote step from its worker result and resume the run — runs on whichever
|
|
2083
2297
|
* instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
|