@adonis-agora/durable 1.0.0-rc.0 → 1.0.0-rc.10
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 +122 -0
- package/dist/assets/spa/assets/index-B2kIfzsc.css +1 -0
- package/dist/assets/spa/assets/index-Bgf_8IJ0.js +16 -0
- package/dist/assets/spa/assets/index-Bgf_8IJ0.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/commands/runs.d.ts.map +1 -1
- package/dist/commands/runs.js +4 -2
- package/dist/commands/runs.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +15 -49
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +69 -244
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +11 -3
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/commands/runs.js +1 -1
- package/dist/src/commands/runs.js.map +1 -1
- package/dist/src/concurrency.d.ts.map +1 -1
- package/dist/src/concurrency.js +1 -0
- package/dist/src/concurrency.js.map +1 -1
- package/dist/src/control-flow-signal.d.ts +5 -4
- package/dist/src/control-flow-signal.d.ts.map +1 -1
- package/dist/src/control-flow-signal.js +5 -4
- package/dist/src/control-flow-signal.js.map +1 -1
- package/dist/src/dashboard/access_denied_page.d.ts +5 -7
- package/dist/src/dashboard/access_denied_page.d.ts.map +1 -1
- package/dist/src/dashboard/access_denied_page.js +2 -6
- package/dist/src/dashboard/access_denied_page.js.map +1 -1
- package/dist/src/dashboard/console_authorize.d.ts +76 -0
- package/dist/src/dashboard/console_authorize.d.ts.map +1 -0
- package/dist/src/dashboard/console_authorize.js +101 -0
- package/dist/src/dashboard/console_authorize.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +44 -81
- package/dist/src/dashboard/define_config.d.ts.map +1 -1
- package/dist/src/dashboard/define_config.js +16 -112
- package/dist/src/dashboard/define_config.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +6 -3
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +5 -8
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +4 -6
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/dashboard/openapi.d.ts.map +1 -1
- package/dist/src/dashboard/openapi.js +11 -1
- package/dist/src/dashboard/openapi.js.map +1 -1
- package/dist/src/dashboard/run-filter.d.ts.map +1 -1
- package/dist/src/dashboard/run-filter.js +1 -0
- package/dist/src/dashboard/run-filter.js.map +1 -1
- package/dist/src/define_config.d.ts +6 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/diagnostics/index.d.ts +11 -0
- package/dist/src/diagnostics/index.d.ts.map +1 -0
- package/dist/src/diagnostics/index.js +6 -0
- package/dist/src/diagnostics/index.js.map +1 -0
- package/dist/src/diagnostics/transport.d.ts +71 -0
- package/dist/src/diagnostics/transport.d.ts.map +1 -0
- package/dist/src/diagnostics/transport.js +188 -0
- package/dist/src/diagnostics/transport.js.map +1 -0
- package/dist/src/diagnostics-bridge.d.ts +2 -1
- package/dist/src/diagnostics-bridge.d.ts.map +1 -1
- package/dist/src/diagnostics-bridge.js +7 -1
- package/dist/src/diagnostics-bridge.js.map +1 -1
- package/dist/src/engine.d.ts +30 -8
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +198 -72
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +9 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +13 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/event-trigger-bridge.d.ts +5 -14
- package/dist/src/event-trigger-bridge.d.ts.map +1 -1
- package/dist/src/event-trigger-bridge.js +11 -99
- package/dist/src/event-trigger-bridge.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 +22 -6
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js +8 -0
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/origin.js +3 -3
- package/dist/src/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler.js +2 -1
- package/dist/src/scheduler.js.map +1 -1
- package/dist/src/singleton-admission.d.ts.map +1 -1
- package/dist/src/singleton-admission.js +2 -0
- package/dist/src/singleton-admission.js.map +1 -1
- package/dist/src/singleton-gate.d.ts +1 -1
- package/dist/src/singleton-gate.d.ts.map +1 -1
- package/dist/src/singleton-gate.js +4 -3
- package/dist/src/singleton-gate.js.map +1 -1
- package/dist/src/stores/lucid-pg-hot-path.d.ts +6 -2
- package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -1
- package/dist/src/stores/lucid-pg-hot-path.js +7 -3
- package/dist/src/stores/lucid-pg-hot-path.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +15 -2
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +191 -49
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +3 -3
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +13 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/transport-conformance.js +5 -1
- package/dist/src/testing-kit/transport-conformance.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +13 -2
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +42 -3
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +44 -3
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts +1 -1
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +4 -7
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-events.d.ts +20 -51
- package/dist/src/workflow-events.d.ts.map +1 -1
- package/dist/src/workflow-events.js +12 -30
- package/dist/src/workflow-events.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +1 -1
- package/dist/src/workflow-turn.d.ts.map +1 -1
- package/dist/src/workflow-turn.js +18 -1
- package/dist/src/workflow-turn.js.map +1 -1
- package/dist/stubs/config/durable_dashboard.stub +9 -29
- package/package.json +12 -2
- package/skills/durable-cluster/SKILL.md +8 -4
- package/skills/durable-reliability/SKILL.md +2 -2
- package/skills/durable-setup/SKILL.md +3 -1
- package/dist/assets/spa/assets/index-70hGzOnT.css +0 -1
- package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
- package/dist/assets/spa/assets/index-CJjMAUau.js.map +0 -1
- package/dist/src/dashboard/auth.d.ts +0 -134
- package/dist/src/dashboard/auth.d.ts.map +0 -1
- package/dist/src/dashboard/auth.js +0 -156
- package/dist/src/dashboard/auth.js.map +0 -1
- package/dist/src/dashboard/login_page.d.ts +0 -36
- package/dist/src/dashboard/login_page.d.ts.map +0 -1
- package/dist/src/dashboard/login_page.js +0 -158
- package/dist/src/dashboard/login_page.js.map +0 -1
- package/dist/src/dashboard/session_cookie.d.ts +0 -45
- package/dist/src/dashboard/session_cookie.d.ts.map +0 -1
- package/dist/src/dashboard/session_cookie.js +0 -97
- package/dist/src/dashboard/session_cookie.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -72,6 +72,13 @@ const isBreakpoint = (cp) => cp.status === 'pending' && cp.name.startsWith(BREAK
|
|
|
72
72
|
* through some other path (an operator's cancel, a timeout sweep, a dead-letter) while this turn's
|
|
73
73
|
* outcome was being computed from a possibly-stale snapshot.
|
|
74
74
|
*/
|
|
75
|
+
/** Statuses a wake refused by a held lease may stamp a request for another turn on. */
|
|
76
|
+
const WAKEABLE_RUN_STATUSES = ['running', 'suspended', 'blocked', 'cancelling'];
|
|
77
|
+
/**
|
|
78
|
+
* Statuses a wake that finds the lease free re-drives: parked runs, and a `cancelling` run whose
|
|
79
|
+
* undo is not executing (its turn unwinds the saga).
|
|
80
|
+
*/
|
|
81
|
+
const PARKED_RUN_STATUSES = ['suspended', 'blocked', 'cancelling'];
|
|
75
82
|
const SETTLE_ELIGIBLE_STATUSES = [
|
|
76
83
|
'pending',
|
|
77
84
|
'running',
|
|
@@ -139,6 +146,12 @@ const STEP_EVENTS_PER_BROADCAST = 500;
|
|
|
139
146
|
const isStepEvent = (event) => event.type.startsWith('step.');
|
|
140
147
|
/** The default {@link WorkflowEngineDeps.timerBatchSize}. */
|
|
141
148
|
const DEFAULT_TIMER_BATCH = 500;
|
|
149
|
+
/** The default {@link WorkflowEngineDeps.waitForRunPollMs}. */
|
|
150
|
+
const DEFAULT_WAIT_FOR_RUN_POLL_MS = 250;
|
|
151
|
+
/** A `waitForRun` read interval: a finite value `>= 0` (`0` = no reads), else `undefined`. */
|
|
152
|
+
function validPollMs(ms) {
|
|
153
|
+
return ms !== undefined && Number.isFinite(ms) && ms >= 0 ? ms : undefined;
|
|
154
|
+
}
|
|
142
155
|
/** {@link WorkflowEngine.sweepBufferedSignals}: rows read per page. */
|
|
143
156
|
const BUFFERED_SWEEP_PAGE = 500;
|
|
144
157
|
/** {@link WorkflowEngine.sweepBufferedSignals}: pages per call, so one tick stays short. */
|
|
@@ -241,7 +254,13 @@ function isNewerVersion(a, b) {
|
|
|
241
254
|
return a.localeCompare(b, undefined, { numeric: true }) > 0;
|
|
242
255
|
}
|
|
243
256
|
/** The statuses a run can be skipped in for an unregistered workflow — every non-terminal one. */
|
|
244
|
-
const UNREGISTERED_DEAD_LETTER_FROM = [
|
|
257
|
+
const UNREGISTERED_DEAD_LETTER_FROM = [
|
|
258
|
+
'pending',
|
|
259
|
+
'running',
|
|
260
|
+
'suspended',
|
|
261
|
+
'blocked',
|
|
262
|
+
'cancelling',
|
|
263
|
+
];
|
|
245
264
|
/** Thrown by {@link WorkflowEngine.resume} when the run belongs to a different namespace. */
|
|
246
265
|
export class NamespaceMismatch extends Error {
|
|
247
266
|
constructor() {
|
|
@@ -313,6 +332,7 @@ export class WorkflowEngine {
|
|
|
313
332
|
remoteRedispatchMs;
|
|
314
333
|
remoteRedispatchMax;
|
|
315
334
|
remoteAdvanceSilenceMs;
|
|
335
|
+
waitForRunPollMs;
|
|
316
336
|
webhookUrl;
|
|
317
337
|
traceparent;
|
|
318
338
|
context;
|
|
@@ -589,6 +609,7 @@ export class WorkflowEngine {
|
|
|
589
609
|
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
590
610
|
// Opt-in: unset (or 0) keeps the unbounded wait for a remote workflow turn's decision.
|
|
591
611
|
this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs || undefined;
|
|
612
|
+
this.waitForRunPollMs = validPollMs(deps.waitForRunPollMs) ?? DEFAULT_WAIT_FOR_RUN_POLL_MS;
|
|
592
613
|
this.webhookUrl = deps.webhookUrl;
|
|
593
614
|
this.traceparent = deps.traceparent;
|
|
594
615
|
this.context = deps.context;
|
|
@@ -3217,7 +3238,7 @@ export class WorkflowEngine {
|
|
|
3217
3238
|
return [];
|
|
3218
3239
|
this.#lastStalledSweepAt = now;
|
|
3219
3240
|
const candidates = await this.store.listRuns({
|
|
3220
|
-
statuses: ['suspended', 'running'],
|
|
3241
|
+
statuses: ['suspended', 'running', 'cancelling'],
|
|
3221
3242
|
namespace: this.namespace,
|
|
3222
3243
|
updatedBefore: now - this.stalledAfterMs,
|
|
3223
3244
|
size: 100,
|
|
@@ -3461,11 +3482,10 @@ export class WorkflowEngine {
|
|
|
3461
3482
|
/**
|
|
3462
3483
|
* Sweep a deleted run's token-keyed buffered signals — they are not run-scoped rows, so the store's
|
|
3463
3484
|
* deleteRun can't cascade them. Without this every fire-and-forget child that was never joined leaks
|
|
3464
|
-
* its buffered `child:<id>` completion FOREVER (one row per spawn)
|
|
3465
|
-
* can leave a `cancel:<id>` marker behind.
|
|
3485
|
+
* its buffered `child:<id>` completion FOREVER (one row per spawn).
|
|
3466
3486
|
*/
|
|
3467
3487
|
async sweepRunBufferedSignals(runId) {
|
|
3468
|
-
for (const token of [`child:${runId}
|
|
3488
|
+
for (const token of [`child:${runId}`]) {
|
|
3469
3489
|
for (;;) {
|
|
3470
3490
|
const buffered = await this.store.takeBufferedSignal(token).catch(() => null);
|
|
3471
3491
|
if (!buffered)
|
|
@@ -4061,6 +4081,27 @@ export class WorkflowEngine {
|
|
|
4061
4081
|
// finished run) and re-enqueues it for a second execution. Only a run that still reads
|
|
4062
4082
|
// `running` under the lease is an orphan; release the lease taken just to look at anything else.
|
|
4063
4083
|
const run = await this.store.getRun(listed.id);
|
|
4084
|
+
// A compensating cancel whose undo died with its pod (or never started: the pod that took the
|
|
4085
|
+
// cancel crashed before its resume ran). Re-drive it rather than re-pend it, which would drop
|
|
4086
|
+
// the cancel: any turn of a `cancelling` run unwinds the saga and settles it `cancelled`.
|
|
4087
|
+
if (run?.status === 'cancelling') {
|
|
4088
|
+
// Only a pod that has the workflow can run its compensations; past the grace, one that is
|
|
4089
|
+
// registered nowhere is dead-lettered like any orphan.
|
|
4090
|
+
if (!this.isRegisteredHere(run) && !(await this.canResolveWorkflow(run))) {
|
|
4091
|
+
const settled = await this.settleUnregistered(run, nowMs);
|
|
4092
|
+
if (settled)
|
|
4093
|
+
results.push(settled);
|
|
4094
|
+
else if (settled === undefined)
|
|
4095
|
+
this.logUnregisteredSkip(run);
|
|
4096
|
+
continue;
|
|
4097
|
+
}
|
|
4098
|
+
await this.store.releaseRunLock(listed.id, this.instanceId);
|
|
4099
|
+
this.trackEffect(Promise.resolve(this.wake(run.id)).catch((error) => {
|
|
4100
|
+
console.error(`[adonis-durable] could not re-drive cancelling run ${run.id}; retrying on a later tick:`, error);
|
|
4101
|
+
}));
|
|
4102
|
+
results.push({ runId: run.id, status: 'cancelling' });
|
|
4103
|
+
continue;
|
|
4104
|
+
}
|
|
4064
4105
|
if (run?.status !== 'running') {
|
|
4065
4106
|
await this.store.releaseRunLock(listed.id, this.instanceId);
|
|
4066
4107
|
continue;
|
|
@@ -4543,6 +4584,10 @@ export class WorkflowEngine {
|
|
|
4543
4584
|
const run = await this.store.getRun(runId);
|
|
4544
4585
|
if (!run)
|
|
4545
4586
|
return null;
|
|
4587
|
+
// A run undoing its saga for a compensating cancel is not retried over: re-pending it would drop
|
|
4588
|
+
// the cancel. It ends `cancelled`, and can be requeued from there.
|
|
4589
|
+
if (run.status === 'cancelling')
|
|
4590
|
+
return { runId, status: 'cancelling' };
|
|
4546
4591
|
// A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
|
|
4547
4592
|
// failed checkpoint rethrows, and an awaited child's failure completion rethrows through
|
|
4548
4593
|
// unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
|
|
@@ -4798,15 +4843,25 @@ export class WorkflowEngine {
|
|
|
4798
4843
|
* the wait continues until the run reaches an end state. This is what `BaseWorkflow.start` needs so
|
|
4799
4844
|
* "I want the result" blocks through a suspension instead of returning early with no output. The
|
|
4800
4845
|
* default (`terminal` unset/false) resolves on any settled state, suspended included.
|
|
4846
|
+
*
|
|
4847
|
+
* A `blocked` run (no live worker can take its next dispatch) is NOT settled in either mode: the
|
|
4848
|
+
* engine re-drives it as soon as a capable worker appears, so the wait continues. Bound it with
|
|
4849
|
+
* `timeoutMs`.
|
|
4850
|
+
*
|
|
4851
|
+
* The wait wakes on this run's lifecycle events, and also re-reads the run every `pollMs` (default
|
|
4852
|
+
* the engine's `waitForRunPollMs`, 250 ms), so it sees a run that settles on another instance even
|
|
4853
|
+
* without a control plane. `pollMs: 0` turns the re-read off.
|
|
4801
4854
|
*/
|
|
4802
4855
|
waitForRun(runId, opts) {
|
|
4803
4856
|
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
|
|
4804
4857
|
// A run `suspended` while it awaits a dispatched remote workflow turn's decision
|
|
4805
4858
|
// (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
|
|
4806
|
-
// marker, not a sleep/signal the body asked for. Keep waiting until the decision lands.
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4859
|
+
// marker, not a sleep/signal the body asked for. Keep waiting until the decision lands. A `blocked`
|
|
4860
|
+
// run is parked on the fleet, not on anything the body asked for, and is re-driven on its own.
|
|
4861
|
+
// A `cancelling` run is still undoing its saga: it settles once it is `cancelled`.
|
|
4862
|
+
const isSettled = (run) => run.status !== 'cancelling' &&
|
|
4863
|
+
(isTerminal(run.status) ||
|
|
4864
|
+
(run.status === 'suspended' && run.awaitingDecisionTaskId === undefined));
|
|
4810
4865
|
const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
|
|
4811
4866
|
const toResult = (run) => ({
|
|
4812
4867
|
runId,
|
|
@@ -4814,37 +4869,56 @@ export class WorkflowEngine {
|
|
|
4814
4869
|
output: run.output,
|
|
4815
4870
|
error: run.error,
|
|
4816
4871
|
});
|
|
4872
|
+
const pollMs = validPollMs(opts?.pollMs) ?? this.waitForRunPollMs;
|
|
4817
4873
|
return new Promise((resolve, reject) => {
|
|
4818
4874
|
let done = false;
|
|
4819
4875
|
let off = () => { };
|
|
4820
|
-
|
|
4821
|
-
|
|
4876
|
+
let poller;
|
|
4877
|
+
let reading = false;
|
|
4878
|
+
let timer;
|
|
4879
|
+
const stop = () => {
|
|
4880
|
+
done = true;
|
|
4881
|
+
if (timer)
|
|
4882
|
+
clearTimeout(timer);
|
|
4883
|
+
if (poller)
|
|
4884
|
+
clearInterval(poller);
|
|
4885
|
+
off();
|
|
4886
|
+
};
|
|
4887
|
+
if (opts?.timeoutMs != null) {
|
|
4888
|
+
timer = setTimeout(() => {
|
|
4822
4889
|
if (done)
|
|
4823
4890
|
return;
|
|
4824
|
-
|
|
4825
|
-
off();
|
|
4891
|
+
stop();
|
|
4826
4892
|
reject(new Error(`waitForRun(${runId}) timed out after ${opts.timeoutMs}ms`));
|
|
4827
|
-
}, opts.timeoutMs)
|
|
4828
|
-
|
|
4893
|
+
}, opts.timeoutMs);
|
|
4894
|
+
}
|
|
4829
4895
|
const finish = (run) => {
|
|
4830
4896
|
if (done)
|
|
4831
4897
|
return;
|
|
4832
|
-
|
|
4833
|
-
if (timer)
|
|
4834
|
-
clearTimeout(timer);
|
|
4835
|
-
off();
|
|
4898
|
+
stop();
|
|
4836
4899
|
resolve(toResult(run));
|
|
4837
4900
|
};
|
|
4901
|
+
// Swallow a transient store error: the next event or poll re-checks, and the timeout still
|
|
4902
|
+
// bounds the wait. Unhandled, one hiccup here was a process-fatal unhandled rejection.
|
|
4903
|
+
const read = () => this.store
|
|
4904
|
+
.getRun(runId)
|
|
4905
|
+
.then((run) => {
|
|
4906
|
+
if (run && shouldResolve(run))
|
|
4907
|
+
finish(run);
|
|
4908
|
+
})
|
|
4909
|
+
.catch(() => undefined);
|
|
4838
4910
|
const check = () => {
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4911
|
+
if (!done)
|
|
4912
|
+
void read();
|
|
4913
|
+
};
|
|
4914
|
+
// The poll skips a tick while its previous read is still out, so a slow store never stacks reads.
|
|
4915
|
+
const poll = () => {
|
|
4916
|
+
if (done || reading)
|
|
4917
|
+
return;
|
|
4918
|
+
reading = true;
|
|
4919
|
+
void read().finally(() => {
|
|
4920
|
+
reading = false;
|
|
4921
|
+
});
|
|
4848
4922
|
};
|
|
4849
4923
|
// React only to this run's settling events (not its every step event), and subscribe BEFORE the
|
|
4850
4924
|
// initial read so a run that settles in between isn't missed.
|
|
@@ -4854,6 +4928,12 @@ export class WorkflowEngine {
|
|
|
4854
4928
|
check();
|
|
4855
4929
|
}
|
|
4856
4930
|
});
|
|
4931
|
+
// Events only reach this instance for runs it executes (or over a control plane, best-effort):
|
|
4932
|
+
// the poll is what sees a run that settles on another pod.
|
|
4933
|
+
if (pollMs > 0) {
|
|
4934
|
+
poller = setInterval(poll, pollMs);
|
|
4935
|
+
poller.unref?.();
|
|
4936
|
+
}
|
|
4857
4937
|
check();
|
|
4858
4938
|
});
|
|
4859
4939
|
}
|
|
@@ -5270,6 +5350,9 @@ export class WorkflowEngine {
|
|
|
5270
5350
|
* cancelled. Without it, cancellation is immediate (no undo).
|
|
5271
5351
|
*/
|
|
5272
5352
|
async cancel(runId, opts) {
|
|
5353
|
+
return this.cancelOnce(runId, opts, true);
|
|
5354
|
+
}
|
|
5355
|
+
async cancelOnce(runId, opts, mayRetry) {
|
|
5273
5356
|
const run = await this.store.getRun(runId);
|
|
5274
5357
|
if (!run)
|
|
5275
5358
|
return null;
|
|
@@ -5282,18 +5365,35 @@ export class WorkflowEngine {
|
|
|
5282
5365
|
if (TERMINAL_RUN_STATUSES.includes(run.status)) {
|
|
5283
5366
|
return { runId, status: run.status, output: run.output, error: run.error };
|
|
5284
5367
|
}
|
|
5368
|
+
// A compensating cancel is already undoing the saga: a repeat cancel, with or without
|
|
5369
|
+
// `compensate`, echoes it. Taking the instant path below would mark it `cancelled` and skip the
|
|
5370
|
+
// undo still in flight. Recovery re-drives a `cancelling` run whose undo died.
|
|
5371
|
+
if (run.status === 'cancelling')
|
|
5372
|
+
return { runId, status: 'cancelling' };
|
|
5285
5373
|
// Compensating cancel: resume the run with a cancellation pending — replay re-registers the saga,
|
|
5286
5374
|
// and at the suspension point execute() runs the undo and marks it cancelled. Run that resume in
|
|
5287
5375
|
// the BACKGROUND so the caller (e.g. an HTTP request) never blocks on replaying the workflow +
|
|
5288
5376
|
// compensations. `execute` holds the run's lease, so this can't double-run one a live worker owns
|
|
5289
5377
|
// (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
|
|
5290
5378
|
if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
|
|
5379
|
+
// DURABLE cancel intent: the persisted `cancelling` status. It survives a crash or a pod
|
|
5380
|
+
// handoff (any turn of a `cancelling` run unwinds it, a turn already executing on another pod
|
|
5381
|
+
// finds it when its settle loses the status race, and crash recovery re-drives one whose undo
|
|
5382
|
+
// died), and shows the dashboard/API that an undo is in progress. Conditional: a run that
|
|
5383
|
+
// moved meanwhile (settled, parked) is re-read and cancelled for what it is now.
|
|
5384
|
+
const marked = await this.store.updateRunIf(runId, ['suspended', 'running'], {
|
|
5385
|
+
status: 'cancelling',
|
|
5386
|
+
updatedAt: new Date(),
|
|
5387
|
+
});
|
|
5388
|
+
if (!marked) {
|
|
5389
|
+
if (mayRetry)
|
|
5390
|
+
return this.cancelOnce(runId, opts, false);
|
|
5391
|
+
const current = await this.store.getRun(runId);
|
|
5392
|
+
return current
|
|
5393
|
+
? { runId, status: current.status, output: current.output, error: current.error }
|
|
5394
|
+
: null;
|
|
5395
|
+
}
|
|
5291
5396
|
this.cancelRequested.add(runId);
|
|
5292
|
-
// DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
|
|
5293
|
-
// buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
|
|
5294
|
-
// picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
|
|
5295
|
-
// the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
|
|
5296
|
-
await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
|
|
5297
5397
|
if (this.controlPlane) {
|
|
5298
5398
|
void this.controlPlane
|
|
5299
5399
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
|
|
@@ -5311,7 +5411,7 @@ export class WorkflowEngine {
|
|
|
5311
5411
|
// needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
|
|
5312
5412
|
this.trackEffect(Promise.resolve().then(() => this.resumeImplicit(runId).then(() => this.notifyCancelled(runId))));
|
|
5313
5413
|
await this.cancelChildren(runId, opts);
|
|
5314
|
-
return { runId, status:
|
|
5414
|
+
return { runId, status: 'cancelling' };
|
|
5315
5415
|
}
|
|
5316
5416
|
const error = { message: 'cancelled' };
|
|
5317
5417
|
// Clears a remote turn's decision marker too: a cancelled run awaits no decision.
|
|
@@ -5648,7 +5748,7 @@ export class WorkflowEngine {
|
|
|
5648
5748
|
publishedAt: this.clock(),
|
|
5649
5749
|
})
|
|
5650
5750
|
.catch(() => undefined);
|
|
5651
|
-
const marked = await this.store.updateRunIf(runId,
|
|
5751
|
+
const marked = await this.store.updateRunIf(runId, WAKEABLE_RUN_STATUSES, {
|
|
5652
5752
|
wakeRequestedAt: this.clock(),
|
|
5653
5753
|
});
|
|
5654
5754
|
return marked ? 'stamped' : 'gone';
|
|
@@ -5687,7 +5787,7 @@ export class WorkflowEngine {
|
|
|
5687
5787
|
if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
|
|
5688
5788
|
return;
|
|
5689
5789
|
const fresh = await this.store.getRun(runId);
|
|
5690
|
-
if (fresh
|
|
5790
|
+
if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
|
|
5691
5791
|
await this.store.releaseRunLock(runId, this.instanceId);
|
|
5692
5792
|
return;
|
|
5693
5793
|
}
|
|
@@ -5787,7 +5887,7 @@ export class WorkflowEngine {
|
|
|
5787
5887
|
}
|
|
5788
5888
|
// Free, and `pending` (its dispatch or the pending-run poll runs it) or `running` with no lease
|
|
5789
5889
|
// (a holder that died mid-turn: crash recovery takes it): nothing for a wake to do.
|
|
5790
|
-
if (
|
|
5890
|
+
if (!PARKED_RUN_STATUSES.includes(run.status))
|
|
5791
5891
|
return;
|
|
5792
5892
|
if (this.runQueue) {
|
|
5793
5893
|
this.enqueueRun({
|
|
@@ -5800,7 +5900,7 @@ export class WorkflowEngine {
|
|
|
5800
5900
|
if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
|
|
5801
5901
|
return;
|
|
5802
5902
|
const fresh = await this.store.getRun(runId);
|
|
5803
|
-
if (fresh
|
|
5903
|
+
if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
|
|
5804
5904
|
await this.store.releaseRunLock(runId, this.instanceId);
|
|
5805
5905
|
return;
|
|
5806
5906
|
}
|
|
@@ -5875,6 +5975,11 @@ export class WorkflowEngine {
|
|
|
5875
5975
|
// A remote (e.g. Python) workflow is advanced by dispatching workflow tasks, not by running an
|
|
5876
5976
|
// in-process body — but everything around it (lease, recovery, timers, the resume that lands us
|
|
5877
5977
|
// here on a step result) is identical, so it branches here under the same lease.
|
|
5978
|
+
// A `cancelling` run is a compensating cancel in flight, maybe re-driven by crash recovery or
|
|
5979
|
+
// by another pod that never saw the in-memory flag: re-derive the intent from the status, so
|
|
5980
|
+
// this turn unwinds the saga and settles `cancelled` at whichever exit it reaches.
|
|
5981
|
+
if (run.status === 'cancelling')
|
|
5982
|
+
this.cancelRequested.add(run.id);
|
|
5878
5983
|
const result = registered?.remote
|
|
5879
5984
|
? await this.runRemoteExecution(run, registered)
|
|
5880
5985
|
: await this.runExecution(run, fn, registered);
|
|
@@ -6265,7 +6370,7 @@ export class WorkflowEngine {
|
|
|
6265
6370
|
// settle — and it must happen even while a dispatched turn is still out (the hold below would
|
|
6266
6371
|
// otherwise swallow the cancel's resume, and the in-flight turn's decision would carry the run on
|
|
6267
6372
|
// as if it was never cancelled). The late decision then finds the run cancelled and is dropped.
|
|
6268
|
-
if (
|
|
6373
|
+
if (this.takeCancelIntent(run.id))
|
|
6269
6374
|
return this.compensateAndCancel(run, []);
|
|
6270
6375
|
// A dispatched turn is still out and within its liveness window: whatever woke the run (a step
|
|
6271
6376
|
// result, a signal, a wait recheck) must not dispatch a second turn over it — that would supersede
|
|
@@ -6358,13 +6463,17 @@ export class WorkflowEngine {
|
|
|
6358
6463
|
// with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
|
|
6359
6464
|
// `maxRecoveryAttempts`).
|
|
6360
6465
|
if (err instanceof RemoteWorkflowTimeout) {
|
|
6361
|
-
await this.store
|
|
6466
|
+
const reset = await this.store
|
|
6362
6467
|
.updateRunIf(run.id, ['running', 'suspended'], {
|
|
6363
6468
|
status: 'running',
|
|
6364
6469
|
updatedAt: new Date(),
|
|
6365
6470
|
})
|
|
6366
6471
|
.catch(() => false);
|
|
6367
6472
|
await this.releaseTurnLease(run.id);
|
|
6473
|
+
// Not reset: the run moved on meanwhile (a compensating cancel made it `cancelling`, or it
|
|
6474
|
+
// settled). Report what it is now, not a `running` it no longer reads.
|
|
6475
|
+
if (!reset)
|
|
6476
|
+
return this.echoCurrentStatus(run.id);
|
|
6368
6477
|
return { runId: run.id, status: 'running' };
|
|
6369
6478
|
}
|
|
6370
6479
|
const error = {
|
|
@@ -7276,21 +7385,25 @@ export class WorkflowEngine {
|
|
|
7276
7385
|
return wakeAt;
|
|
7277
7386
|
}
|
|
7278
7387
|
/**
|
|
7279
|
-
* Whether a compensating cancel is pending for this run, consuming the in-memory flag. The
|
|
7280
|
-
*
|
|
7281
|
-
*
|
|
7282
|
-
*
|
|
7283
|
-
*
|
|
7284
|
-
* `cancelled` is durably recorded.
|
|
7388
|
+
* Whether a compensating cancel is pending for this run, consuming the in-memory flag. The flag is
|
|
7389
|
+
* set by `cancel()` on this pod, and at turn start for a run that reads `cancelling` (the durable
|
|
7390
|
+
* intent: a cancel from another pod, or one re-driven by recovery). A cancel another pod persists
|
|
7391
|
+
* while this turn runs is found at the settle instead: the run no longer reads a settle-eligible
|
|
7392
|
+
* status, and {@link settleOrUnwind} unwinds it.
|
|
7285
7393
|
*/
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7394
|
+
takeCancelIntent(runId) {
|
|
7395
|
+
return this.cancelRequested.delete(runId);
|
|
7396
|
+
}
|
|
7397
|
+
/**
|
|
7398
|
+
* Settle this turn's `outcome` — unless a compensating cancel was persisted on another pod while the
|
|
7399
|
+
* turn ran: the settle's status check then reads `cancelling`, and this turn (which still holds the
|
|
7400
|
+
* lease and the saga it registered) undoes it and settles `cancelled` itself.
|
|
7401
|
+
*/
|
|
7402
|
+
async settleOrUnwind(run, outcome, compensations) {
|
|
7403
|
+
const settled = await this.settleRun(run, outcome);
|
|
7404
|
+
if (settled.status !== 'cancelling' || this.leaseLostRuns.has(run.id))
|
|
7405
|
+
return settled;
|
|
7406
|
+
return this.compensateAndCancel(run, compensations);
|
|
7294
7407
|
}
|
|
7295
7408
|
/**
|
|
7296
7409
|
* Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
|
|
@@ -7300,8 +7413,8 @@ export class WorkflowEngine {
|
|
|
7300
7413
|
*/
|
|
7301
7414
|
async compensateAndCancel(run, compensations) {
|
|
7302
7415
|
this.cancelRequested.delete(run.id);
|
|
7303
|
-
// Zombie fence (as in settleRun): another instance owns the run now; the
|
|
7304
|
-
// there for it, so this stale turn must neither undo nor write.
|
|
7416
|
+
// Zombie fence (as in settleRun): another instance owns the run now; the persisted `cancelling`
|
|
7417
|
+
// status is still there for it, so this stale turn must neither undo nor write.
|
|
7305
7418
|
if (this.leaseLostRuns.has(run.id))
|
|
7306
7419
|
return this.echoCurrentStatus(run.id);
|
|
7307
7420
|
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
@@ -7316,13 +7429,6 @@ export class WorkflowEngine {
|
|
|
7316
7429
|
awaitingDecisionTaskId: undefined,
|
|
7317
7430
|
updatedAt: new Date(),
|
|
7318
7431
|
}));
|
|
7319
|
-
// The cancellation is durably recorded — consume the marker(s) so a later retry of this run id
|
|
7320
|
-
// doesn't get spuriously re-cancelled.
|
|
7321
|
-
for (;;) {
|
|
7322
|
-
const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
|
|
7323
|
-
if (!m)
|
|
7324
|
-
break;
|
|
7325
|
-
}
|
|
7326
7432
|
this.emit({
|
|
7327
7433
|
type: 'run.failed',
|
|
7328
7434
|
runId: run.id,
|
|
@@ -7367,7 +7473,11 @@ export class WorkflowEngine {
|
|
|
7367
7473
|
}
|
|
7368
7474
|
// Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
|
|
7369
7475
|
// (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
|
|
7370
|
-
|
|
7476
|
+
// A `cancelling` run skips it: it is tearing down, not competing for a slot, and parking it here
|
|
7477
|
+
// would overwrite `cancelling` with `suspended`.
|
|
7478
|
+
if (run.status !== 'cancelling' &&
|
|
7479
|
+
registered?.singleton &&
|
|
7480
|
+
!(await this.singletons.admit(run, registered.singleton))) {
|
|
7371
7481
|
return this.singletonWaitResult(run);
|
|
7372
7482
|
}
|
|
7373
7483
|
// A body this instance parked on dispatched steps in an earlier turn continues from there (see
|
|
@@ -7446,10 +7556,10 @@ export class WorkflowEngine {
|
|
|
7446
7556
|
}
|
|
7447
7557
|
// A compensating cancel requested while this turn ran (or one whose resume replays straight to
|
|
7448
7558
|
// `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
|
|
7449
|
-
if (
|
|
7559
|
+
if (this.takeCancelIntent(run.id)) {
|
|
7450
7560
|
return await this.compensateAndCancel(run, compensations);
|
|
7451
7561
|
}
|
|
7452
|
-
return await this.
|
|
7562
|
+
return await this.settleOrUnwind(run, { kind: 'completed', output }, compensations);
|
|
7453
7563
|
}
|
|
7454
7564
|
catch (err) {
|
|
7455
7565
|
if (err instanceof ContinueAsNew) {
|
|
@@ -7458,7 +7568,11 @@ export class WorkflowEngine {
|
|
|
7458
7568
|
if (this.leaseLostRuns.has(run.id))
|
|
7459
7569
|
return await this.echoCurrentStatus(run.id);
|
|
7460
7570
|
// A cancel in flight wins over continue-as-new: undo + cancel instead of forking the next run.
|
|
7461
|
-
|
|
7571
|
+
// The run is re-read here (continue-as-new is rare) so a cancel another pod persisted during
|
|
7572
|
+
// this turn usually stops the fork; one that lands after the read loses the conditional
|
|
7573
|
+
// completion below instead.
|
|
7574
|
+
if (this.takeCancelIntent(run.id) ||
|
|
7575
|
+
(await this.store.getRun(run.id))?.status === 'cancelling') {
|
|
7462
7576
|
return await this.compensateAndCancel(run, compensations);
|
|
7463
7577
|
}
|
|
7464
7578
|
const nextId = nextContinuationId(run.id);
|
|
@@ -7473,12 +7587,23 @@ export class WorkflowEngine {
|
|
|
7473
7587
|
// `dispatch: false`: the tracked handoff below owns the pickup. The continuation inherits
|
|
7474
7588
|
// the parent RUN's namespace so a tenant's chain stays in its worker-pool partition.
|
|
7475
7589
|
await this.startCore(run.workflow, err.input, nextId, { namespace: run.namespace }, false);
|
|
7476
|
-
|
|
7590
|
+
// Conditional, like every settle: a cancel persisted after the re-read above (`cancelling`, or a
|
|
7591
|
+
// plain `cancelled`) must win over this completion. The continuation already exists then, and
|
|
7592
|
+
// nothing has handed it off: cancel it, and unwind this run if the cancel compensates.
|
|
7593
|
+
const completed = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
|
|
7477
7594
|
status: 'completed',
|
|
7478
7595
|
output: undefined,
|
|
7479
7596
|
error: undefined,
|
|
7480
7597
|
updatedAt: new Date(),
|
|
7481
7598
|
});
|
|
7599
|
+
if (!completed) {
|
|
7600
|
+
await this.cancel(nextId);
|
|
7601
|
+
const current = await this.store.getRun(run.id);
|
|
7602
|
+
if (current?.status === 'cancelling' && !this.leaseLostRuns.has(run.id)) {
|
|
7603
|
+
return await this.compensateAndCancel(run, compensations);
|
|
7604
|
+
}
|
|
7605
|
+
return await this.echoCurrentStatus(run.id);
|
|
7606
|
+
}
|
|
7482
7607
|
this.emit({
|
|
7483
7608
|
type: 'run.completed',
|
|
7484
7609
|
runId: run.id,
|
|
@@ -7502,10 +7627,10 @@ export class WorkflowEngine {
|
|
|
7502
7627
|
if (err instanceof WorkflowSuspended) {
|
|
7503
7628
|
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
7504
7629
|
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
7505
|
-
if (
|
|
7630
|
+
if (this.takeCancelIntent(run.id)) {
|
|
7506
7631
|
return await this.compensateAndCancel(run, compensations);
|
|
7507
7632
|
}
|
|
7508
|
-
const settled = await this.
|
|
7633
|
+
const settled = await this.settleOrUnwind(run, { kind: 'suspended', wakeAt: err.wakeAt }, compensations);
|
|
7509
7634
|
const stamp = this.suspendStamps.get(run.id);
|
|
7510
7635
|
this.suspendStamps.delete(run.id);
|
|
7511
7636
|
if (parkedTurn && cont && settled.status === 'suspended' && stamp !== undefined) {
|
|
@@ -7530,7 +7655,7 @@ export class WorkflowEngine {
|
|
|
7530
7655
|
}
|
|
7531
7656
|
// A cancel in flight that surfaced as a thrown error settles `cancelled`, not `failed`: the saga
|
|
7532
7657
|
// undo runs either way; this keeps the terminal status faithful to the cancel request.
|
|
7533
|
-
if (
|
|
7658
|
+
if (this.takeCancelIntent(run.id)) {
|
|
7534
7659
|
return await this.compensateAndCancel(run, compensations);
|
|
7535
7660
|
}
|
|
7536
7661
|
const error = {
|
|
@@ -7548,7 +7673,8 @@ export class WorkflowEngine {
|
|
|
7548
7673
|
continue;
|
|
7549
7674
|
await this.runCompensation(run, comp, i);
|
|
7550
7675
|
}
|
|
7551
|
-
|
|
7676
|
+
// The saga is undone already: a cancel persisted meanwhile only needs the `cancelled` settle.
|
|
7677
|
+
return await this.settleOrUnwind(run, { kind: 'failed', error }, []);
|
|
7552
7678
|
}
|
|
7553
7679
|
finally {
|
|
7554
7680
|
// Release the recovery lease once the run reaches a terminal/suspended state, so the
|