@adonis-agora/durable 1.0.0-rc.1 → 1.0.0-rc.3
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 +23 -0
- package/dist/assets/spa/assets/index-DSdVNM0u.js +16 -0
- package/dist/assets/spa/assets/index-DSdVNM0u.js.map +1 -0
- package/dist/assets/spa/index.html +1 -1
- 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/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/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +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/engine.d.ts +1 -0
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +93 -16
- package/dist/src/engine.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/interfaces.d.ts +17 -3
- 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/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.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +5 -3
- 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/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/package.json +2 -2
- package/dist/assets/spa/assets/index-CJjMAUau.js +0 -16
- package/dist/assets/spa/assets/index-CJjMAUau.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',
|
|
@@ -247,7 +254,13 @@ function isNewerVersion(a, b) {
|
|
|
247
254
|
return a.localeCompare(b, undefined, { numeric: true }) > 0;
|
|
248
255
|
}
|
|
249
256
|
/** The statuses a run can be skipped in for an unregistered workflow — every non-terminal one. */
|
|
250
|
-
const UNREGISTERED_DEAD_LETTER_FROM = [
|
|
257
|
+
const UNREGISTERED_DEAD_LETTER_FROM = [
|
|
258
|
+
'pending',
|
|
259
|
+
'running',
|
|
260
|
+
'suspended',
|
|
261
|
+
'blocked',
|
|
262
|
+
'cancelling',
|
|
263
|
+
];
|
|
251
264
|
/** Thrown by {@link WorkflowEngine.resume} when the run belongs to a different namespace. */
|
|
252
265
|
export class NamespaceMismatch extends Error {
|
|
253
266
|
constructor() {
|
|
@@ -3225,7 +3238,7 @@ export class WorkflowEngine {
|
|
|
3225
3238
|
return [];
|
|
3226
3239
|
this.#lastStalledSweepAt = now;
|
|
3227
3240
|
const candidates = await this.store.listRuns({
|
|
3228
|
-
statuses: ['suspended', 'running'],
|
|
3241
|
+
statuses: ['suspended', 'running', 'cancelling'],
|
|
3229
3242
|
namespace: this.namespace,
|
|
3230
3243
|
updatedBefore: now - this.stalledAfterMs,
|
|
3231
3244
|
size: 100,
|
|
@@ -4069,6 +4082,27 @@ export class WorkflowEngine {
|
|
|
4069
4082
|
// finished run) and re-enqueues it for a second execution. Only a run that still reads
|
|
4070
4083
|
// `running` under the lease is an orphan; release the lease taken just to look at anything else.
|
|
4071
4084
|
const run = await this.store.getRun(listed.id);
|
|
4085
|
+
// A compensating cancel whose undo died with its pod (or never started: the pod that took the
|
|
4086
|
+
// cancel crashed before its resume ran). Re-drive it rather than re-pend it, which would drop
|
|
4087
|
+
// the cancel: any turn of a `cancelling` run unwinds the saga and settles it `cancelled`.
|
|
4088
|
+
if (run?.status === 'cancelling') {
|
|
4089
|
+
// Only a pod that has the workflow can run its compensations; past the grace, one that is
|
|
4090
|
+
// registered nowhere is dead-lettered like any orphan.
|
|
4091
|
+
if (!this.isRegisteredHere(run) && !(await this.canResolveWorkflow(run))) {
|
|
4092
|
+
const settled = await this.settleUnregistered(run, nowMs);
|
|
4093
|
+
if (settled)
|
|
4094
|
+
results.push(settled);
|
|
4095
|
+
else if (settled === undefined)
|
|
4096
|
+
this.logUnregisteredSkip(run);
|
|
4097
|
+
continue;
|
|
4098
|
+
}
|
|
4099
|
+
await this.store.releaseRunLock(listed.id, this.instanceId);
|
|
4100
|
+
this.trackEffect(Promise.resolve(this.wake(run.id)).catch((error) => {
|
|
4101
|
+
console.error(`[adonis-durable] could not re-drive cancelling run ${run.id}; retrying on a later tick:`, error);
|
|
4102
|
+
}));
|
|
4103
|
+
results.push({ runId: run.id, status: 'cancelling' });
|
|
4104
|
+
continue;
|
|
4105
|
+
}
|
|
4072
4106
|
if (run?.status !== 'running') {
|
|
4073
4107
|
await this.store.releaseRunLock(listed.id, this.instanceId);
|
|
4074
4108
|
continue;
|
|
@@ -4551,6 +4585,10 @@ export class WorkflowEngine {
|
|
|
4551
4585
|
const run = await this.store.getRun(runId);
|
|
4552
4586
|
if (!run)
|
|
4553
4587
|
return null;
|
|
4588
|
+
// A run undoing its saga for a compensating cancel is not retried over: re-pending it would drop
|
|
4589
|
+
// the cancel. It ends `cancelled`, and can be requeued from there.
|
|
4590
|
+
if (run.status === 'cancelling')
|
|
4591
|
+
return { runId, status: 'cancelling' };
|
|
4554
4592
|
// A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
|
|
4555
4593
|
// failed checkpoint rethrows, and an awaited child's failure completion rethrows through
|
|
4556
4594
|
// unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
|
|
@@ -4821,8 +4859,10 @@ export class WorkflowEngine {
|
|
|
4821
4859
|
// (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
|
|
4822
4860
|
// marker, not a sleep/signal the body asked for. Keep waiting until the decision lands. A `blocked`
|
|
4823
4861
|
// run is parked on the fleet, not on anything the body asked for, and is re-driven on its own.
|
|
4824
|
-
|
|
4825
|
-
|
|
4862
|
+
// A `cancelling` run is still undoing its saga: it settles once it is `cancelled`.
|
|
4863
|
+
const isSettled = (run) => run.status !== 'cancelling' &&
|
|
4864
|
+
(isTerminal(run.status) ||
|
|
4865
|
+
(run.status === 'suspended' && run.awaitingDecisionTaskId === undefined));
|
|
4826
4866
|
const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
|
|
4827
4867
|
const toResult = (run) => ({
|
|
4828
4868
|
runId,
|
|
@@ -5311,6 +5351,9 @@ export class WorkflowEngine {
|
|
|
5311
5351
|
* cancelled. Without it, cancellation is immediate (no undo).
|
|
5312
5352
|
*/
|
|
5313
5353
|
async cancel(runId, opts) {
|
|
5354
|
+
return this.cancelOnce(runId, opts, true);
|
|
5355
|
+
}
|
|
5356
|
+
async cancelOnce(runId, opts, mayRetry) {
|
|
5314
5357
|
const run = await this.store.getRun(runId);
|
|
5315
5358
|
if (!run)
|
|
5316
5359
|
return null;
|
|
@@ -5323,18 +5366,39 @@ export class WorkflowEngine {
|
|
|
5323
5366
|
if (TERMINAL_RUN_STATUSES.includes(run.status)) {
|
|
5324
5367
|
return { runId, status: run.status, output: run.output, error: run.error };
|
|
5325
5368
|
}
|
|
5369
|
+
// A compensating cancel is already undoing the saga: a repeat cancel, with or without
|
|
5370
|
+
// `compensate`, echoes it. Taking the instant path below would mark it `cancelled` and skip the
|
|
5371
|
+
// undo still in flight. Recovery re-drives a `cancelling` run whose undo died.
|
|
5372
|
+
if (run.status === 'cancelling')
|
|
5373
|
+
return { runId, status: 'cancelling' };
|
|
5326
5374
|
// Compensating cancel: resume the run with a cancellation pending — replay re-registers the saga,
|
|
5327
5375
|
// and at the suspension point execute() runs the undo and marks it cancelled. Run that resume in
|
|
5328
5376
|
// the BACKGROUND so the caller (e.g. an HTTP request) never blocks on replaying the workflow +
|
|
5329
5377
|
// compensations. `execute` holds the run's lease, so this can't double-run one a live worker owns
|
|
5330
5378
|
// (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
|
|
5331
5379
|
if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
|
|
5380
|
+
// The buffered `cancel:<id>` marker tells a turn already executing on ANOTHER pod (its exits
|
|
5381
|
+
// re-read it) and pods of an older version to unwind. Written first and NOT swallowed: a failed
|
|
5382
|
+
// write fails the cancel before anything changed, so the caller can retry.
|
|
5383
|
+
await this.store.bufferSignal(`cancel:${runId}`, true);
|
|
5384
|
+
// DURABLE cancel intent: the persisted `cancelling` status. It survives a crash or a pod
|
|
5385
|
+
// handoff (any turn of a `cancelling` run unwinds it, and crash recovery re-drives one whose
|
|
5386
|
+
// undo died), and shows the dashboard/API that an undo is in progress. Conditional: a run that
|
|
5387
|
+
// moved meanwhile (settled, parked) is re-read and cancelled for what it is now.
|
|
5388
|
+
const marked = await this.store.updateRunIf(runId, ['suspended', 'running'], {
|
|
5389
|
+
status: 'cancelling',
|
|
5390
|
+
updatedAt: new Date(),
|
|
5391
|
+
});
|
|
5392
|
+
if (!marked) {
|
|
5393
|
+
await this.store.takeBufferedSignal(`cancel:${runId}`).catch(() => null);
|
|
5394
|
+
if (mayRetry)
|
|
5395
|
+
return this.cancelOnce(runId, opts, false);
|
|
5396
|
+
const current = await this.store.getRun(runId);
|
|
5397
|
+
return current
|
|
5398
|
+
? { runId, status: current.status, output: current.output, error: current.error }
|
|
5399
|
+
: null;
|
|
5400
|
+
}
|
|
5332
5401
|
this.cancelRequested.add(runId);
|
|
5333
|
-
// DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
|
|
5334
|
-
// buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
|
|
5335
|
-
// picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
|
|
5336
|
-
// the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
|
|
5337
|
-
await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
|
|
5338
5402
|
if (this.controlPlane) {
|
|
5339
5403
|
void this.controlPlane
|
|
5340
5404
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
|
|
@@ -5352,7 +5416,7 @@ export class WorkflowEngine {
|
|
|
5352
5416
|
// needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
|
|
5353
5417
|
this.trackEffect(Promise.resolve().then(() => this.resumeImplicit(runId).then(() => this.notifyCancelled(runId))));
|
|
5354
5418
|
await this.cancelChildren(runId, opts);
|
|
5355
|
-
return { runId, status:
|
|
5419
|
+
return { runId, status: 'cancelling' };
|
|
5356
5420
|
}
|
|
5357
5421
|
const error = { message: 'cancelled' };
|
|
5358
5422
|
// Clears a remote turn's decision marker too: a cancelled run awaits no decision.
|
|
@@ -5689,7 +5753,7 @@ export class WorkflowEngine {
|
|
|
5689
5753
|
publishedAt: this.clock(),
|
|
5690
5754
|
})
|
|
5691
5755
|
.catch(() => undefined);
|
|
5692
|
-
const marked = await this.store.updateRunIf(runId,
|
|
5756
|
+
const marked = await this.store.updateRunIf(runId, WAKEABLE_RUN_STATUSES, {
|
|
5693
5757
|
wakeRequestedAt: this.clock(),
|
|
5694
5758
|
});
|
|
5695
5759
|
return marked ? 'stamped' : 'gone';
|
|
@@ -5728,7 +5792,7 @@ export class WorkflowEngine {
|
|
|
5728
5792
|
if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
|
|
5729
5793
|
return;
|
|
5730
5794
|
const fresh = await this.store.getRun(runId);
|
|
5731
|
-
if (fresh
|
|
5795
|
+
if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
|
|
5732
5796
|
await this.store.releaseRunLock(runId, this.instanceId);
|
|
5733
5797
|
return;
|
|
5734
5798
|
}
|
|
@@ -5828,7 +5892,7 @@ export class WorkflowEngine {
|
|
|
5828
5892
|
}
|
|
5829
5893
|
// Free, and `pending` (its dispatch or the pending-run poll runs it) or `running` with no lease
|
|
5830
5894
|
// (a holder that died mid-turn: crash recovery takes it): nothing for a wake to do.
|
|
5831
|
-
if (
|
|
5895
|
+
if (!PARKED_RUN_STATUSES.includes(run.status))
|
|
5832
5896
|
return;
|
|
5833
5897
|
if (this.runQueue) {
|
|
5834
5898
|
this.enqueueRun({
|
|
@@ -5841,7 +5905,7 @@ export class WorkflowEngine {
|
|
|
5841
5905
|
if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
|
|
5842
5906
|
return;
|
|
5843
5907
|
const fresh = await this.store.getRun(runId);
|
|
5844
|
-
if (fresh
|
|
5908
|
+
if (!fresh || !PARKED_RUN_STATUSES.includes(fresh.status)) {
|
|
5845
5909
|
await this.store.releaseRunLock(runId, this.instanceId);
|
|
5846
5910
|
return;
|
|
5847
5911
|
}
|
|
@@ -5916,6 +5980,11 @@ export class WorkflowEngine {
|
|
|
5916
5980
|
// A remote (e.g. Python) workflow is advanced by dispatching workflow tasks, not by running an
|
|
5917
5981
|
// in-process body — but everything around it (lease, recovery, timers, the resume that lands us
|
|
5918
5982
|
// here on a step result) is identical, so it branches here under the same lease.
|
|
5983
|
+
// A `cancelling` run is a compensating cancel in flight, maybe re-driven by crash recovery or
|
|
5984
|
+
// by another pod that never saw the in-memory flag: re-derive the intent from the status, so
|
|
5985
|
+
// this turn unwinds the saga and settles `cancelled` at whichever exit it reaches.
|
|
5986
|
+
if (run.status === 'cancelling')
|
|
5987
|
+
this.cancelRequested.add(run.id);
|
|
5919
5988
|
const result = registered?.remote
|
|
5920
5989
|
? await this.runRemoteExecution(run, registered)
|
|
5921
5990
|
: await this.runExecution(run, fn, registered);
|
|
@@ -6399,13 +6468,17 @@ export class WorkflowEngine {
|
|
|
6399
6468
|
// with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
|
|
6400
6469
|
// `maxRecoveryAttempts`).
|
|
6401
6470
|
if (err instanceof RemoteWorkflowTimeout) {
|
|
6402
|
-
await this.store
|
|
6471
|
+
const reset = await this.store
|
|
6403
6472
|
.updateRunIf(run.id, ['running', 'suspended'], {
|
|
6404
6473
|
status: 'running',
|
|
6405
6474
|
updatedAt: new Date(),
|
|
6406
6475
|
})
|
|
6407
6476
|
.catch(() => false);
|
|
6408
6477
|
await this.releaseTurnLease(run.id);
|
|
6478
|
+
// Not reset: the run moved on meanwhile (a compensating cancel made it `cancelling`, or it
|
|
6479
|
+
// settled). Report what it is now, not a `running` it no longer reads.
|
|
6480
|
+
if (!reset)
|
|
6481
|
+
return this.echoCurrentStatus(run.id);
|
|
6409
6482
|
return { runId: run.id, status: 'running' };
|
|
6410
6483
|
}
|
|
6411
6484
|
const error = {
|
|
@@ -7408,7 +7481,11 @@ export class WorkflowEngine {
|
|
|
7408
7481
|
}
|
|
7409
7482
|
// Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
|
|
7410
7483
|
// (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
|
|
7411
|
-
|
|
7484
|
+
// A `cancelling` run skips it: it is tearing down, not competing for a slot, and parking it here
|
|
7485
|
+
// would overwrite `cancelling` with `suspended`.
|
|
7486
|
+
if (run.status !== 'cancelling' &&
|
|
7487
|
+
registered?.singleton &&
|
|
7488
|
+
!(await this.singletons.admit(run, registered.singleton))) {
|
|
7412
7489
|
return this.singletonWaitResult(run);
|
|
7413
7490
|
}
|
|
7414
7491
|
// A body this instance parked on dispatched steps in an earlier turn continues from there (see
|