@adonis-agora/durable 0.6.0 → 0.7.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 +14 -0
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +41 -4
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/define_config.d.ts +8 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/engine.d.ts +40 -2
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +129 -28
- package/dist/src/engine.js.map +1 -1
- package/dist/src/entities.js +2 -2
- package/dist/src/entities.js.map +1 -1
- package/dist/src/hooks/steps.d.ts +43 -0
- package/dist/src/hooks/steps.d.ts.map +1 -0
- package/dist/src/hooks/steps.js +46 -0
- package/dist/src/hooks/steps.js.map +1 -0
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +116 -42
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/step-discovery.d.ts +42 -0
- package/dist/src/step-discovery.d.ts.map +1 -0
- package/dist/src/step-discovery.js +127 -0
- package/dist/src/step-discovery.js.map +1 -0
- package/dist/src/step-name-symbol.d.ts +63 -0
- package/dist/src/step-name-symbol.d.ts.map +1 -0
- package/dist/src/step-name-symbol.js +42 -0
- package/dist/src/step-name-symbol.js.map +1 -0
- package/dist/src/step-ref.d.ts +70 -0
- package/dist/src/step-ref.d.ts.map +1 -0
- package/dist/src/step-ref.js +93 -0
- package/dist/src/step-ref.js.map +1 -0
- package/dist/src/tenant-group.d.ts +18 -0
- package/dist/src/tenant-group.d.ts.map +1 -0
- package/dist/src/tenant-group.js +24 -0
- package/dist/src/tenant-group.js.map +1 -0
- package/dist/src/testing-kit/state-store-conformance.js +2 -2
- 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 +7 -12
- package/dist/src/testing-kit/transport-conformance.js.map +1 -1
- package/dist/src/transports/db-schema.d.ts.map +1 -1
- package/dist/src/transports/db-schema.js +24 -4
- package/dist/src/transports/db-schema.js.map +1 -1
- package/dist/src/transports/db.d.ts +34 -5
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +58 -15
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts +15 -5
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +19 -17
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts +27 -8
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +101 -16
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/stubs/database/migrations/create_durable_transport_tables.stub +8 -4
- package/dist/stubs/make/workflow/main.stub +1 -1
- package/package.json +6 -1
- package/dist/src/remote-step-factory.d.ts +0 -15
- package/dist/src/remote-step-factory.d.ts.map +0 -1
- package/dist/src/remote-step-factory.js +0 -12
- package/dist/src/remote-step-factory.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -7,7 +7,9 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
|
|
|
7
7
|
import { EventAccumulators } from './event-accumulators.js';
|
|
8
8
|
import { eventMatchOf, eventMatches, eventPrefix } from './events.js';
|
|
9
9
|
import { breakpointToken, stepId } from './protocol.js';
|
|
10
|
+
import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
|
|
10
11
|
import { SingletonGate } from './singleton-gate.js';
|
|
12
|
+
import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
|
|
11
13
|
import { TransportPool } from './transport-pool.js';
|
|
12
14
|
import { createWorkflowCtx, } from './workflow-ctx.js';
|
|
13
15
|
import { workflowName, } from './workflow-ref.js';
|
|
@@ -52,6 +54,11 @@ export class WorkflowEngine {
|
|
|
52
54
|
store;
|
|
53
55
|
/** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
|
|
54
56
|
pool;
|
|
57
|
+
/** The primary task transport (first of the pool), used to build an on-the-fly remote-workflow
|
|
58
|
+
* executor for {@link WorkflowEngineDeps.remoteByConvention}. Undefined when no transport is wired. */
|
|
59
|
+
primaryTransport;
|
|
60
|
+
/** Convention-dispatch opt-in — see {@link WorkflowEngineDeps.remoteByConvention}. */
|
|
61
|
+
remoteByConvention;
|
|
55
62
|
controlPlane;
|
|
56
63
|
clock;
|
|
57
64
|
instanceId;
|
|
@@ -110,6 +117,8 @@ export class WorkflowEngine {
|
|
|
110
117
|
constructor(deps) {
|
|
111
118
|
this.store = deps.store;
|
|
112
119
|
this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
|
|
120
|
+
this.primaryTransport = deps.transports?.[0]?.transport ?? deps.transport;
|
|
121
|
+
this.remoteByConvention = deps.remoteByConvention ?? false;
|
|
113
122
|
this.controlPlane = deps.controlPlane;
|
|
114
123
|
this.clock = deps.clock ?? Date.now;
|
|
115
124
|
this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
|
|
@@ -294,7 +303,28 @@ export class WorkflowEngine {
|
|
|
294
303
|
this.latest.set(name, registered);
|
|
295
304
|
}
|
|
296
305
|
/**
|
|
297
|
-
*
|
|
306
|
+
* Convention-dispatch resolution (opt-in via {@link WorkflowEngineDeps.remoteByConvention}): when
|
|
307
|
+
* `name` isn't locally registered but a LIVE worker group of the same name exists, auto-register a
|
|
308
|
+
* remote workflow routed to that group (bare group, no partition) so its runs advance over the
|
|
309
|
+
* broker like any `registerRemote`'d one. Returns whether `name` is registered afterwards. A no-op
|
|
310
|
+
* (returns `false`) when the opt-in is off, no transport is wired, or no matching group is live.
|
|
311
|
+
*/
|
|
312
|
+
async ensureConventionWorkflow(name) {
|
|
313
|
+
if (!this.remoteByConvention || !this.primaryTransport)
|
|
314
|
+
return false;
|
|
315
|
+
if (this.latest.get(name))
|
|
316
|
+
return true;
|
|
317
|
+
const groups = await this.pool.listWorkerGroups();
|
|
318
|
+
if (!groups.includes(name))
|
|
319
|
+
return false;
|
|
320
|
+
this.registerRemote(name, '1', {
|
|
321
|
+
group: name,
|
|
322
|
+
executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
|
|
323
|
+
});
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Register a flow-control queue referenced by `ctx.step(name, input, { queue })`. Caps concurrent
|
|
298
328
|
* in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
|
|
299
329
|
* durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
|
|
300
330
|
*/
|
|
@@ -394,7 +424,12 @@ export class WorkflowEngine {
|
|
|
394
424
|
}
|
|
395
425
|
async start(workflow, input, runId, opts) {
|
|
396
426
|
const name = workflowName(workflow);
|
|
397
|
-
|
|
427
|
+
let registered = this.latest.get(name);
|
|
428
|
+
// remoteByConvention: an unregistered workflow whose name matches a LIVE worker group is routed to
|
|
429
|
+
// it as a remote workflow (bare group). Off by default → the original "not registered" throw.
|
|
430
|
+
if (!registered && (await this.ensureConventionWorkflow(name))) {
|
|
431
|
+
registered = this.latest.get(name);
|
|
432
|
+
}
|
|
398
433
|
if (!registered)
|
|
399
434
|
throw new Error(`workflow ${name} is not registered`);
|
|
400
435
|
// Validate the input up front — a bad payload is rejected before any run is created.
|
|
@@ -421,7 +456,7 @@ export class WorkflowEngine {
|
|
|
421
456
|
workflow: name,
|
|
422
457
|
workflowVersion: registered.version,
|
|
423
458
|
status: 'pending',
|
|
424
|
-
namespace: this.namespace,
|
|
459
|
+
namespace: opts?.namespace ?? this.namespace,
|
|
425
460
|
input,
|
|
426
461
|
tags,
|
|
427
462
|
searchAttributes: opts?.searchAttributes,
|
|
@@ -496,7 +531,14 @@ export class WorkflowEngine {
|
|
|
496
531
|
}
|
|
497
532
|
// Pin to the version the run STARTED on — replay is positional, so running a changed
|
|
498
533
|
// workflow body against old checkpoints would corrupt the run.
|
|
499
|
-
|
|
534
|
+
let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
535
|
+
// remoteByConvention: a run recovered on an instance that never registered this workflow (e.g. a
|
|
536
|
+
// crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
|
|
537
|
+
if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
|
|
538
|
+
registered =
|
|
539
|
+
this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
|
|
540
|
+
this.latest.get(run.workflow);
|
|
541
|
+
}
|
|
500
542
|
if (!registered) {
|
|
501
543
|
throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
|
|
502
544
|
}
|
|
@@ -550,7 +592,7 @@ export class WorkflowEngine {
|
|
|
550
592
|
continue;
|
|
551
593
|
const error = { message: 'execution timeout', code: 'execution_timeout' };
|
|
552
594
|
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
553
|
-
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
595
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
|
|
554
596
|
}
|
|
555
597
|
}
|
|
556
598
|
}
|
|
@@ -597,7 +639,7 @@ export class WorkflowEngine {
|
|
|
597
639
|
};
|
|
598
640
|
await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
|
|
599
641
|
await this.store.releaseRunLock(run.id);
|
|
600
|
-
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
642
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
|
|
601
643
|
this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
|
|
602
644
|
return { runId: run.id, status: 'dead', error };
|
|
603
645
|
}
|
|
@@ -659,7 +701,9 @@ export class WorkflowEngine {
|
|
|
659
701
|
if (!run)
|
|
660
702
|
return null;
|
|
661
703
|
const id = newRunId ?? `${runId}~retry~${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
662
|
-
|
|
704
|
+
// Inherit the original run's namespace so a retry lands in the SAME worker-pool partition (an
|
|
705
|
+
// operator-routed run keeps its tenant on retry, rather than falling back to the engine's own).
|
|
706
|
+
await this.start(run.workflow, input, id, { tags: run.tags, namespace: run.namespace });
|
|
663
707
|
return { runId: id };
|
|
664
708
|
}
|
|
665
709
|
/**
|
|
@@ -885,7 +929,7 @@ export class WorkflowEngine {
|
|
|
885
929
|
}
|
|
886
930
|
const error = { message: 'cancelled' };
|
|
887
931
|
await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
|
|
888
|
-
this.emit({ type: 'run.failed', runId, workflow: run.workflow, error });
|
|
932
|
+
this.emit({ type: 'run.failed', runId, workflow: run.workflow, namespace: run.namespace, error });
|
|
889
933
|
await this.cancelChildren(runId, opts);
|
|
890
934
|
// Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
|
|
891
935
|
// actually running this run learns of it and can abort cooperatively (the store already records
|
|
@@ -1204,6 +1248,7 @@ export class WorkflowEngine {
|
|
|
1204
1248
|
type: 'run.completed',
|
|
1205
1249
|
runId: run.id,
|
|
1206
1250
|
workflow: run.workflow,
|
|
1251
|
+
namespace: run.namespace,
|
|
1207
1252
|
output: outcome.output,
|
|
1208
1253
|
});
|
|
1209
1254
|
void this.notifyParent(run.id, { ok: true, value: outcome.output });
|
|
@@ -1215,13 +1260,14 @@ export class WorkflowEngine {
|
|
|
1215
1260
|
type: 'run.failed',
|
|
1216
1261
|
runId: run.id,
|
|
1217
1262
|
workflow: run.workflow,
|
|
1263
|
+
namespace: run.namespace,
|
|
1218
1264
|
error: outcome.error,
|
|
1219
1265
|
});
|
|
1220
1266
|
void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
|
|
1221
1267
|
return { runId: run.id, status: 'failed', error: outcome.error };
|
|
1222
1268
|
}
|
|
1223
1269
|
await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
|
|
1224
|
-
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
|
|
1270
|
+
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
|
|
1225
1271
|
return { runId: run.id, status: 'suspended' };
|
|
1226
1272
|
}
|
|
1227
1273
|
async runRemoteExecution(run, registered) {
|
|
@@ -1229,12 +1275,12 @@ export class WorkflowEngine {
|
|
|
1229
1275
|
if (run.status === 'pending') {
|
|
1230
1276
|
await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
|
|
1231
1277
|
run.status = 'running';
|
|
1232
|
-
this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
|
|
1278
|
+
this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow, namespace: run.namespace });
|
|
1233
1279
|
}
|
|
1234
1280
|
if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
1235
1281
|
const wakeAt = this.singletons.retryWakeAt();
|
|
1236
1282
|
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
1237
|
-
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
|
|
1283
|
+
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
|
|
1238
1284
|
await this.store.releaseRunLock(run.id);
|
|
1239
1285
|
return { runId: run.id, status: 'suspended' };
|
|
1240
1286
|
}
|
|
@@ -1249,7 +1295,7 @@ export class WorkflowEngine {
|
|
|
1249
1295
|
stack: err instanceof Error ? err.stack : undefined,
|
|
1250
1296
|
};
|
|
1251
1297
|
await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
|
|
1252
|
-
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
1298
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
|
|
1253
1299
|
return { runId: run.id, status: 'failed', error };
|
|
1254
1300
|
}
|
|
1255
1301
|
if (decision.status === 'completed') {
|
|
@@ -1375,6 +1421,10 @@ export class WorkflowEngine {
|
|
|
1375
1421
|
// Mirrors the `startChild` `getRun(childId)` guard below.
|
|
1376
1422
|
if (await this.store.getCheckpoint(run.id, cmd.seq))
|
|
1377
1423
|
continue;
|
|
1424
|
+
// Route this cross-SDK-worker `call` by the SAME name-based token a native `ctx.step`
|
|
1425
|
+
// dispatches with (and a worker subscribes to per handler name), so a decision-driven remote
|
|
1426
|
+
// step and an in-process one land on the identical queue. The command carries no partition.
|
|
1427
|
+
const callToken = tenantGroup(sanitizeQueueToken(cmd.name), undefined);
|
|
1378
1428
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
1379
1429
|
runId: run.id,
|
|
1380
1430
|
seq: cmd.seq,
|
|
@@ -1383,13 +1433,13 @@ export class WorkflowEngine {
|
|
|
1383
1433
|
status: 'pending',
|
|
1384
1434
|
input: cmd.input,
|
|
1385
1435
|
attempts: 1,
|
|
1386
|
-
workerGroup:
|
|
1436
|
+
workerGroup: callToken,
|
|
1387
1437
|
enqueuedAt: at,
|
|
1388
1438
|
startedAt: at,
|
|
1389
1439
|
finishedAt: at,
|
|
1390
1440
|
// A `ctx.gather_calls([...])` fan-out stamps every dispatched `call` in the fan with the
|
|
1391
1441
|
// same group, so the dashboard renders the remote steps as one parallel fan (parity with
|
|
1392
|
-
// the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.
|
|
1442
|
+
// the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.step`.
|
|
1393
1443
|
parallelGroup: cmd.parallelGroup,
|
|
1394
1444
|
}));
|
|
1395
1445
|
await this.pool.dispatch({
|
|
@@ -1397,7 +1447,7 @@ export class WorkflowEngine {
|
|
|
1397
1447
|
seq: cmd.seq,
|
|
1398
1448
|
name: cmd.name,
|
|
1399
1449
|
stepId: id,
|
|
1400
|
-
group:
|
|
1450
|
+
group: callToken,
|
|
1401
1451
|
input: cmd.input,
|
|
1402
1452
|
traceparent: this.traceparent?.(),
|
|
1403
1453
|
context: this.context?.(),
|
|
@@ -1483,14 +1533,14 @@ export class WorkflowEngine {
|
|
|
1483
1533
|
if (run.status === 'pending') {
|
|
1484
1534
|
await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
|
|
1485
1535
|
run.status = 'running';
|
|
1486
|
-
this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
|
|
1536
|
+
this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow, namespace: run.namespace });
|
|
1487
1537
|
}
|
|
1488
1538
|
// Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
|
|
1489
1539
|
// (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
|
|
1490
1540
|
if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
|
|
1491
1541
|
const wakeAt = this.singletons.retryWakeAt();
|
|
1492
1542
|
await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
|
|
1493
|
-
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
|
|
1543
|
+
this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
|
|
1494
1544
|
await this.store.releaseRunLock(run.id);
|
|
1495
1545
|
return { runId: run.id, status: 'suspended' };
|
|
1496
1546
|
}
|
|
@@ -1521,7 +1571,7 @@ export class WorkflowEngine {
|
|
|
1521
1571
|
error: undefined,
|
|
1522
1572
|
updatedAt: new Date(),
|
|
1523
1573
|
});
|
|
1524
|
-
this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow });
|
|
1574
|
+
this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow, namespace: run.namespace });
|
|
1525
1575
|
void this.notifyParent(run.id, { ok: true, value: undefined });
|
|
1526
1576
|
const nextId = nextContinuationId(run.id);
|
|
1527
1577
|
queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
|
|
@@ -1539,7 +1589,7 @@ export class WorkflowEngine {
|
|
|
1539
1589
|
}
|
|
1540
1590
|
const error = { message: 'cancelled' };
|
|
1541
1591
|
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
1542
|
-
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
1592
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
|
|
1543
1593
|
return { runId: run.id, status: 'cancelled', error };
|
|
1544
1594
|
}
|
|
1545
1595
|
return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
|
|
@@ -1569,14 +1619,21 @@ export class WorkflowEngine {
|
|
|
1569
1619
|
/**
|
|
1570
1620
|
* Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
|
|
1571
1621
|
* step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
|
|
1572
|
-
* compensation is skipped so it can't mask the original failure.
|
|
1622
|
+
* compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
|
|
1623
|
+
* a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
|
|
1624
|
+
* awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
|
|
1573
1625
|
*/
|
|
1574
1626
|
async runCompensation(run, comp) {
|
|
1575
1627
|
const name = `compensate:${comp.name}`;
|
|
1576
1628
|
for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
|
|
1577
1629
|
const startedAt = Date.now();
|
|
1578
1630
|
try {
|
|
1579
|
-
|
|
1631
|
+
if ('fn' in comp) {
|
|
1632
|
+
await comp.fn();
|
|
1633
|
+
}
|
|
1634
|
+
else {
|
|
1635
|
+
await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
|
|
1636
|
+
}
|
|
1580
1637
|
this.emit({
|
|
1581
1638
|
type: 'step.completed',
|
|
1582
1639
|
runId: run.id,
|
|
@@ -1602,6 +1659,43 @@ export class WorkflowEngine {
|
|
|
1602
1659
|
}
|
|
1603
1660
|
}
|
|
1604
1661
|
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Dispatch a saga undo step to a worker and await its result INLINE (the saga unwind already runs
|
|
1664
|
+
* outside the positional replay, so this is not checkpointed — it's a one-shot dispatch + await).
|
|
1665
|
+
* Routes by the undo def's own name/partition token, applies its own optional input schema, and
|
|
1666
|
+
* honours its own liveness `timeoutMs` via the heartbeat window. Throws on a worker-reported failure
|
|
1667
|
+
* or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
|
|
1668
|
+
*/
|
|
1669
|
+
async dispatchCompensation(run, def, args, attempt) {
|
|
1670
|
+
if (this.pool.size === 0)
|
|
1671
|
+
throw new Error('dispatched compensation requires a Transport');
|
|
1672
|
+
const token = tenantGroup(sanitizeQueueToken(def.name), def.partition);
|
|
1673
|
+
const validInput = def.input ? def.input.parse(args) : args;
|
|
1674
|
+
const id = `${run.id}:compensate:${def.name}:${attempt}`;
|
|
1675
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
1676
|
+
this.pending.set(id, { resolve, reject });
|
|
1677
|
+
});
|
|
1678
|
+
try {
|
|
1679
|
+
await this.pool.dispatch({
|
|
1680
|
+
runId: run.id,
|
|
1681
|
+
seq: -1,
|
|
1682
|
+
name: def.name,
|
|
1683
|
+
stepId: id,
|
|
1684
|
+
group: token,
|
|
1685
|
+
input: validInput,
|
|
1686
|
+
traceparent: this.traceparent?.(),
|
|
1687
|
+
context: this.context?.(),
|
|
1688
|
+
attempt: 1,
|
|
1689
|
+
});
|
|
1690
|
+
await (def.timeoutMs
|
|
1691
|
+
? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
|
|
1692
|
+
: resultPromise);
|
|
1693
|
+
}
|
|
1694
|
+
catch (err) {
|
|
1695
|
+
this.pending.delete(id);
|
|
1696
|
+
throw err;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1605
1699
|
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
1606
1700
|
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
1607
1701
|
* module and the engine stays the orchestrator. */
|
|
@@ -1760,7 +1854,13 @@ export class WorkflowEngine {
|
|
|
1760
1854
|
this.queueWaiters.get(queue)?.delete(runId);
|
|
1761
1855
|
this.stepQueue.set(id, queue);
|
|
1762
1856
|
}
|
|
1763
|
-
|
|
1857
|
+
// Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
|
|
1858
|
+
// call carries none — the engine passes the input through untouched then).
|
|
1859
|
+
const validInput = step.input ? step.input.parse(input) : input;
|
|
1860
|
+
// Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
|
|
1861
|
+
// used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
|
|
1862
|
+
// token the worker subscribes to per handler name serves this step.
|
|
1863
|
+
const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
|
|
1764
1864
|
const enqueuedAt = new Date();
|
|
1765
1865
|
// Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
|
|
1766
1866
|
await this.store.saveCheckpoint({
|
|
@@ -1772,7 +1872,7 @@ export class WorkflowEngine {
|
|
|
1772
1872
|
status: 'pending',
|
|
1773
1873
|
input: validInput,
|
|
1774
1874
|
attempts: attempt,
|
|
1775
|
-
workerGroup:
|
|
1875
|
+
workerGroup: token,
|
|
1776
1876
|
enqueuedAt,
|
|
1777
1877
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
1778
1878
|
finishedAt: enqueuedAt,
|
|
@@ -1782,7 +1882,7 @@ export class WorkflowEngine {
|
|
|
1782
1882
|
seq,
|
|
1783
1883
|
name: step.name,
|
|
1784
1884
|
stepId: id,
|
|
1785
|
-
group:
|
|
1885
|
+
group: token,
|
|
1786
1886
|
input: validInput,
|
|
1787
1887
|
traceparent: this.traceparent?.(),
|
|
1788
1888
|
context: this.context?.(),
|
|
@@ -1859,7 +1959,8 @@ export class WorkflowEngine {
|
|
|
1859
1959
|
async callRemoteInMemory(runId, seq, step, input, transport) {
|
|
1860
1960
|
if (this.pool.size === 0)
|
|
1861
1961
|
throw new Error('remote steps require a Transport');
|
|
1862
|
-
const validInput = step.input.parse(input);
|
|
1962
|
+
const validInput = step.input ? step.input.parse(input) : input;
|
|
1963
|
+
const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
|
|
1863
1964
|
const id = stepId(runId, seq);
|
|
1864
1965
|
const enqueuedAt = new Date();
|
|
1865
1966
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
@@ -1878,7 +1979,7 @@ export class WorkflowEngine {
|
|
|
1878
1979
|
seq,
|
|
1879
1980
|
name: step.name,
|
|
1880
1981
|
stepId: id,
|
|
1881
|
-
group:
|
|
1982
|
+
group: token,
|
|
1882
1983
|
input: validInput,
|
|
1883
1984
|
traceparent: this.traceparent?.(),
|
|
1884
1985
|
context: this.context?.(),
|
|
@@ -1888,7 +1989,7 @@ export class WorkflowEngine {
|
|
|
1888
1989
|
const resolution = step.timeoutMs
|
|
1889
1990
|
? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)
|
|
1890
1991
|
: await resultPromise;
|
|
1891
|
-
const output = step.output.parse(resolution.output);
|
|
1992
|
+
const output = (step.output ? step.output.parse(resolution.output) : resolution.output);
|
|
1892
1993
|
// The worker reports when it actually picked the task up; fall back to dispatch time if a
|
|
1893
1994
|
// transport doesn't carry it (queue-wait then reads as zero rather than going negative).
|
|
1894
1995
|
const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
|
|
@@ -1901,7 +2002,7 @@ export class WorkflowEngine {
|
|
|
1901
2002
|
output,
|
|
1902
2003
|
events: resolution.events,
|
|
1903
2004
|
attempts: attempt,
|
|
1904
|
-
workerGroup:
|
|
2005
|
+
workerGroup: token,
|
|
1905
2006
|
enqueuedAt,
|
|
1906
2007
|
startedAt,
|
|
1907
2008
|
});
|