@dudousxd/nestjs-durable-core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backoff.d.ts +5 -0
- package/dist/backoff.d.ts.map +1 -0
- package/dist/backoff.js +14 -0
- package/dist/backoff.js.map +1 -0
- package/dist/checkpoints.d.ts +17 -0
- package/dist/checkpoints.d.ts.map +1 -0
- package/dist/checkpoints.js +28 -0
- package/dist/checkpoints.js.map +1 -0
- package/dist/completion.d.ts +15 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +13 -0
- package/dist/completion.js.map +1 -0
- package/dist/engine.d.ts +106 -10
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +263 -276
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +113 -17
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/protocol.d.ts +3 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +13 -1
- package/dist/protocol.js.map +1 -1
- package/dist/queue.d.ts +48 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +45 -0
- package/dist/queue.js.map +1 -0
- package/dist/scheduler.d.ts +22 -7
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +40 -6
- package/dist/scheduler.js.map +1 -1
- package/dist/testing/in-memory-transport.d.ts +6 -4
- package/dist/testing/in-memory-transport.d.ts.map +1 -1
- package/dist/testing/in-memory-transport.js +4 -2
- package/dist/testing/in-memory-transport.js.map +1 -1
- package/dist/transport-pool.d.ts +23 -0
- package/dist/transport-pool.d.ts.map +1 -0
- package/dist/transport-pool.js +54 -0
- package/dist/transport-pool.js.map +1 -0
- package/dist/workflow-ctx.d.ts +46 -0
- package/dist/workflow-ctx.d.ts.map +1 -0
- package/dist/workflow-ctx.js +260 -0
- package/dist/workflow-ctx.js.map +1 -0
- package/package.json +8 -1
package/dist/engine.js
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RemoteStepError = exports.WorkflowEngine = void 0;
|
|
4
|
-
const
|
|
4
|
+
const backoff_1 = require("./backoff");
|
|
5
|
+
const checkpoints_1 = require("./checkpoints");
|
|
5
6
|
const errors_1 = require("./errors");
|
|
6
7
|
const protocol_1 = require("./protocol");
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
/** Delay in ms before the next retry of a local step, per its `StepOptions` backoff config. */
|
|
11
|
-
function backoffDelay(attempt, options) {
|
|
12
|
-
const base = options?.backoffMs ?? 0;
|
|
13
|
-
if (base <= 0)
|
|
14
|
-
return 0;
|
|
15
|
-
const raw = options?.backoff === 'exp' ? base * 2 ** (attempt - 1) : base;
|
|
16
|
-
const capped = options?.backoffMaxMs ? Math.min(raw, options.backoffMaxMs) : raw;
|
|
17
|
-
return options?.jitter ? Math.round(capped * (0.5 + Math.random() * 0.5)) : capped;
|
|
18
|
-
}
|
|
8
|
+
const queue_1 = require("./queue");
|
|
9
|
+
const transport_pool_1 = require("./transport-pool");
|
|
10
|
+
const workflow_ctx_1 = require("./workflow-ctx");
|
|
19
11
|
/** A breakpoint checkpoint's `name` is `breakpoint` (or `breakpoint:<label>`). This name — not the
|
|
20
12
|
* reused `signal` kind — is the explicit marker the dashboard and `continue()` detect it by. */
|
|
21
13
|
const BREAKPOINT = 'breakpoint';
|
|
@@ -29,14 +21,6 @@ function isNewerVersion(a, b) {
|
|
|
29
21
|
return na > nb;
|
|
30
22
|
return a.localeCompare(b, undefined, { numeric: true }) > 0;
|
|
31
23
|
}
|
|
32
|
-
/** Read a `Completion` from a signal payload: return the value, or throw a FatalError if it failed. */
|
|
33
|
-
function unwrapCompletion(payload, label) {
|
|
34
|
-
const c = payload;
|
|
35
|
-
if (c && typeof c === 'object' && 'ok' in c && c.ok === false) {
|
|
36
|
-
throw new errors_1.FatalError(`${label} failed: ${c.error}`);
|
|
37
|
-
}
|
|
38
|
-
return c?.value;
|
|
39
|
-
}
|
|
40
24
|
/**
|
|
41
25
|
* The orchestrator. Owns workflow state and replays runs deterministically: each step's
|
|
42
26
|
* result is checkpointed, so on resume a completed step returns its saved output instead of
|
|
@@ -45,10 +29,16 @@ function unwrapCompletion(payload, label) {
|
|
|
45
29
|
*/
|
|
46
30
|
class WorkflowEngine {
|
|
47
31
|
store;
|
|
48
|
-
transport
|
|
32
|
+
/** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
|
|
33
|
+
pool;
|
|
34
|
+
controlPlane;
|
|
49
35
|
clock;
|
|
50
36
|
instanceId;
|
|
51
37
|
leaseMs;
|
|
38
|
+
maxRecoveryAttempts;
|
|
39
|
+
webhookUrl;
|
|
40
|
+
traceparent;
|
|
41
|
+
compensationRetries;
|
|
52
42
|
/** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
|
|
53
43
|
workflows = new Map();
|
|
54
44
|
/** The newest registered version per workflow name — used to `start` new runs. */
|
|
@@ -60,16 +50,29 @@ class WorkflowEngine {
|
|
|
60
50
|
listeners = new Set();
|
|
61
51
|
/** Callbacks notified (on any instance) when a run is cancelled — for cooperative cancellation. */
|
|
62
52
|
cancelListeners = new Set();
|
|
53
|
+
/** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
|
|
54
|
+
updateValidators = new Map();
|
|
55
|
+
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
56
|
+
cancelRequested = new Set();
|
|
57
|
+
/** Flow-control queues for remote steps, keyed by name (see {@link registerQueue}). */
|
|
58
|
+
queues = new Map();
|
|
59
|
+
/** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
|
|
60
|
+
stepQueue = new Map();
|
|
63
61
|
/** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
|
|
64
62
|
inflight = new Set();
|
|
65
63
|
draining = false;
|
|
66
64
|
constructor(deps) {
|
|
67
65
|
this.store = deps.store;
|
|
68
|
-
this.
|
|
66
|
+
this.pool = new transport_pool_1.TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
|
|
67
|
+
this.controlPlane = deps.controlPlane;
|
|
69
68
|
this.clock = deps.clock ?? Date.now;
|
|
70
69
|
this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
|
|
71
70
|
this.leaseMs = deps.leaseMs ?? 30_000;
|
|
72
|
-
this.
|
|
71
|
+
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
72
|
+
this.webhookUrl = deps.webhookUrl;
|
|
73
|
+
this.traceparent = deps.traceparent;
|
|
74
|
+
this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
|
|
75
|
+
this.pool.bind(async (result) => {
|
|
73
76
|
// In-memory path (a `timeoutMs` step awaiting on THIS instance): resolve its pending promise.
|
|
74
77
|
const waiter = this.pending.get(result.stepId);
|
|
75
78
|
if (waiter) {
|
|
@@ -89,15 +92,15 @@ class WorkflowEngine {
|
|
|
89
92
|
// Durable path: no in-memory waiter (the step suspended the run, possibly on another
|
|
90
93
|
// instance) → complete the checkpoint and resume the run here.
|
|
91
94
|
await this.completeRemoteResult(result);
|
|
92
|
-
}
|
|
95
|
+
},
|
|
93
96
|
// A heartbeat for an in-flight long step resets its liveness window (see callRemote).
|
|
94
|
-
|
|
97
|
+
async (beat) => {
|
|
95
98
|
this.heartbeatResets.get(beat.stepId)?.();
|
|
96
99
|
});
|
|
97
100
|
// Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
|
|
98
101
|
// subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
|
|
99
102
|
// echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
|
|
100
|
-
this.
|
|
103
|
+
this.controlPlane?.onControl((msg) => {
|
|
101
104
|
if (msg.from === this.instanceId)
|
|
102
105
|
return;
|
|
103
106
|
if (msg.kind === 'event') {
|
|
@@ -132,6 +135,14 @@ class WorkflowEngine {
|
|
|
132
135
|
if (!current || isNewerVersion(version, current.version))
|
|
133
136
|
this.latest.set(name, registered);
|
|
134
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
|
|
140
|
+
* in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
|
|
141
|
+
* durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
|
|
142
|
+
*/
|
|
143
|
+
registerQueue(config) {
|
|
144
|
+
this.queues.set(config.name, new queue_1.QueueController(config, this.clock));
|
|
145
|
+
}
|
|
135
146
|
/** Subscribe to lifecycle events. Returns an unsubscribe function. */
|
|
136
147
|
subscribe(listener) {
|
|
137
148
|
this.listeners.add(listener);
|
|
@@ -151,8 +162,8 @@ class WorkflowEngine {
|
|
|
151
162
|
emit(event) {
|
|
152
163
|
const full = { ...event, at: new Date() };
|
|
153
164
|
this.deliver(full);
|
|
154
|
-
if (this.
|
|
155
|
-
void this.
|
|
165
|
+
if (this.controlPlane) {
|
|
166
|
+
void this.controlPlane
|
|
156
167
|
.publishControl({ kind: 'event', event: full, from: this.instanceId })
|
|
157
168
|
.catch(() => {
|
|
158
169
|
// control-plane delivery is best-effort observability; never break execution
|
|
@@ -202,7 +213,7 @@ class WorkflowEngine {
|
|
|
202
213
|
// A definitively-finished run must not be re-executed (e.g. a worker result landing after the
|
|
203
214
|
// run was cancelled, or a duplicate resume) — that would replay the body and clobber the
|
|
204
215
|
// terminal state. `failed` is intentionally NOT terminal here: retry resumes a failed run.
|
|
205
|
-
if (run.status === 'cancelled' || run.status === 'completed') {
|
|
216
|
+
if (run.status === 'cancelled' || run.status === 'completed' || run.status === 'dead') {
|
|
206
217
|
return { runId, status: run.status, output: run.output, error: run.error };
|
|
207
218
|
}
|
|
208
219
|
// Pin to the version the run STARTED on — replay is positional, so running a changed
|
|
@@ -238,8 +249,29 @@ class WorkflowEngine {
|
|
|
238
249
|
* Resume every run left incomplete by a crash or deploy. Called on boot. Completed steps
|
|
239
250
|
* replay from their checkpoints, so only the work that had not finished runs again.
|
|
240
251
|
*/
|
|
241
|
-
async recoverIncomplete() {
|
|
242
|
-
return this.resumeLeased(await this.store.listIncompleteRuns());
|
|
252
|
+
async recoverIncomplete(nowMs = this.clock()) {
|
|
253
|
+
return this.resumeLeased(await this.store.listIncompleteRuns(), nowMs, (run) => this.countRecovery(run));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
|
|
257
|
+
* `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
|
|
258
|
+
* result to skip the resume, or `undefined` to proceed.
|
|
259
|
+
*/
|
|
260
|
+
async countRecovery(run) {
|
|
261
|
+
const attempts = (run.recoveryAttempts ?? 0) + 1;
|
|
262
|
+
if (this.maxRecoveryAttempts != null && attempts > this.maxRecoveryAttempts) {
|
|
263
|
+
const error = {
|
|
264
|
+
message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
|
|
265
|
+
code: 'max_recovery_attempts',
|
|
266
|
+
};
|
|
267
|
+
await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
|
|
268
|
+
await this.store.releaseRunLock(run.id);
|
|
269
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
270
|
+
return { runId: run.id, status: 'dead', error };
|
|
271
|
+
}
|
|
272
|
+
// Count BEFORE resuming, so a crash mid-resume still advances the counter.
|
|
273
|
+
await this.store.updateRun(run.id, { recoveryAttempts: attempts, updatedAt: new Date() });
|
|
274
|
+
return undefined;
|
|
243
275
|
}
|
|
244
276
|
/**
|
|
245
277
|
* Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
|
|
@@ -252,14 +284,17 @@ class WorkflowEngine {
|
|
|
252
284
|
* Resume each run only if this instance can acquire its recovery lease — so when several
|
|
253
285
|
* replicas recover or poll at once, each run is picked up by exactly one of them.
|
|
254
286
|
*/
|
|
255
|
-
async resumeLeased(runs, nowMs = this.clock()) {
|
|
287
|
+
async resumeLeased(runs, nowMs = this.clock(), onLocked) {
|
|
256
288
|
if (this.draining)
|
|
257
289
|
return []; // shutting down — don't pick up new runs
|
|
258
290
|
const results = [];
|
|
259
291
|
for (const run of runs) {
|
|
260
292
|
const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
261
|
-
if (acquired)
|
|
262
|
-
|
|
293
|
+
if (!acquired)
|
|
294
|
+
continue;
|
|
295
|
+
// A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
|
|
296
|
+
const settled = onLocked ? await onLocked(run) : undefined;
|
|
297
|
+
results.push(settled ?? (await this.resume(run.id)));
|
|
263
298
|
}
|
|
264
299
|
return results;
|
|
265
300
|
}
|
|
@@ -271,20 +306,13 @@ class WorkflowEngine {
|
|
|
271
306
|
const waiter = await this.store.takeSignalWaiter(token);
|
|
272
307
|
if (!waiter)
|
|
273
308
|
return null;
|
|
274
|
-
|
|
275
|
-
await this.store.saveCheckpoint({
|
|
309
|
+
await this.store.saveCheckpoint((0, checkpoints_1.instantCheckpoint)({
|
|
276
310
|
runId: waiter.runId,
|
|
277
311
|
seq: waiter.seq,
|
|
278
312
|
name: `signal:${token}`,
|
|
279
313
|
kind: 'signal',
|
|
280
|
-
stepId: (0, protocol_1.stepId)(waiter.runId, waiter.seq),
|
|
281
|
-
status: 'completed',
|
|
282
314
|
output: payload,
|
|
283
|
-
|
|
284
|
-
enqueuedAt: at,
|
|
285
|
-
startedAt: at,
|
|
286
|
-
finishedAt: at,
|
|
287
|
-
});
|
|
315
|
+
}));
|
|
288
316
|
return this.resume(waiter.runId);
|
|
289
317
|
}
|
|
290
318
|
/**
|
|
@@ -310,11 +338,29 @@ class WorkflowEngine {
|
|
|
310
338
|
notifyParent(runId, completion) {
|
|
311
339
|
void this.signal(`child:${runId}`, completion).catch(() => undefined);
|
|
312
340
|
}
|
|
313
|
-
/**
|
|
314
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
|
|
343
|
+
* `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
|
|
344
|
+
* steps' compensations run in reverse (visible as `compensate:<step>` events), then it's marked
|
|
345
|
+
* cancelled. Without it, cancellation is immediate (no undo).
|
|
346
|
+
*/
|
|
347
|
+
async cancel(runId, opts) {
|
|
315
348
|
const run = await this.store.getRun(runId);
|
|
316
349
|
if (!run)
|
|
317
350
|
return null;
|
|
351
|
+
// Compensating cancel: resume the run with a cancellation pending. Replay re-registers the
|
|
352
|
+
// saga, and at the run's suspension point execute() runs the undo and marks it cancelled.
|
|
353
|
+
if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
|
|
354
|
+
this.cancelRequested.add(runId);
|
|
355
|
+
const result = await this.resume(runId);
|
|
356
|
+
this.notifyCancelled(runId);
|
|
357
|
+
if (this.controlPlane) {
|
|
358
|
+
void this.controlPlane
|
|
359
|
+
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
360
|
+
.catch(() => undefined);
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
318
364
|
const error = { message: 'cancelled' };
|
|
319
365
|
await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
|
|
320
366
|
this.emit({ type: 'run.failed', runId, workflow: run.workflow, error });
|
|
@@ -322,8 +368,8 @@ class WorkflowEngine {
|
|
|
322
368
|
// actually running this run learns of it and can abort cooperatively (the store already records
|
|
323
369
|
// `cancelled`, but a busy worker won't re-read it).
|
|
324
370
|
this.notifyCancelled(runId);
|
|
325
|
-
if (this.
|
|
326
|
-
void this.
|
|
371
|
+
if (this.controlPlane) {
|
|
372
|
+
void this.controlPlane
|
|
327
373
|
.publishControl({ kind: 'cancel', runId, from: this.instanceId })
|
|
328
374
|
.catch(() => undefined);
|
|
329
375
|
}
|
|
@@ -390,7 +436,7 @@ class WorkflowEngine {
|
|
|
390
436
|
async execute(run, fn) {
|
|
391
437
|
// Saga compensations registered by completed steps; run in reverse if the run later fails.
|
|
392
438
|
const compensations = [];
|
|
393
|
-
const ctx = this.
|
|
439
|
+
const ctx = (0, workflow_ctx_1.createWorkflowCtx)(this.ctxHost, run.id, compensations);
|
|
394
440
|
try {
|
|
395
441
|
const output = await fn(ctx, run.input);
|
|
396
442
|
// Clear any error from an earlier failed-then-retried attempt: a completed run is a success
|
|
@@ -408,6 +454,20 @@ class WorkflowEngine {
|
|
|
408
454
|
}
|
|
409
455
|
catch (err) {
|
|
410
456
|
if (err instanceof errors_1.WorkflowSuspended) {
|
|
457
|
+
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
458
|
+
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
459
|
+
if (this.cancelRequested.has(run.id)) {
|
|
460
|
+
this.cancelRequested.delete(run.id);
|
|
461
|
+
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
462
|
+
const comp = compensations[i];
|
|
463
|
+
if (comp)
|
|
464
|
+
await this.runCompensation(run, comp);
|
|
465
|
+
}
|
|
466
|
+
const error = { message: 'cancelled' };
|
|
467
|
+
await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
|
|
468
|
+
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
469
|
+
return { runId: run.id, status: 'cancelled', error };
|
|
470
|
+
}
|
|
411
471
|
await this.store.updateRun(run.id, {
|
|
412
472
|
status: 'suspended',
|
|
413
473
|
wakeAt: err.wakeAt,
|
|
@@ -420,15 +480,15 @@ class WorkflowEngine {
|
|
|
420
480
|
message: err instanceof Error ? err.message : String(err),
|
|
421
481
|
stack: err instanceof Error ? err.stack : undefined,
|
|
422
482
|
};
|
|
423
|
-
// Saga: undo completed steps in reverse
|
|
424
|
-
//
|
|
483
|
+
// Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
|
|
484
|
+
// are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
|
|
485
|
+
// swallowed) in the dashboard/telescope; a failing one is still skipped so it can't mask the
|
|
486
|
+
// original failure or strand the rest. (Compensations should be idempotent.)
|
|
425
487
|
for (let i = compensations.length - 1; i >= 0; i -= 1) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
// a failing compensation must not mask the original failure
|
|
431
|
-
}
|
|
488
|
+
const comp = compensations[i];
|
|
489
|
+
if (!comp)
|
|
490
|
+
continue;
|
|
491
|
+
await this.runCompensation(run, comp);
|
|
432
492
|
}
|
|
433
493
|
await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
|
|
434
494
|
this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
|
|
@@ -441,219 +501,59 @@ class WorkflowEngine {
|
|
|
441
501
|
await this.store.releaseRunLock(run.id);
|
|
442
502
|
}
|
|
443
503
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
compensations.push(options.compensate);
|
|
465
|
-
return existing.output;
|
|
504
|
+
/**
|
|
505
|
+
* Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
|
|
506
|
+
* step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
|
|
507
|
+
* compensation is skipped so it can't mask the original failure.
|
|
508
|
+
*/
|
|
509
|
+
async runCompensation(run, comp) {
|
|
510
|
+
const name = `compensate:${comp.name}`;
|
|
511
|
+
for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
|
|
512
|
+
const startedAt = Date.now();
|
|
513
|
+
try {
|
|
514
|
+
await comp.fn();
|
|
515
|
+
this.emit({
|
|
516
|
+
type: 'step.completed',
|
|
517
|
+
runId: run.id,
|
|
518
|
+
workflow: run.workflow,
|
|
519
|
+
name,
|
|
520
|
+
kind: 'local',
|
|
521
|
+
durationMs: Date.now() - startedAt,
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
466
524
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
try {
|
|
474
|
-
const output = await fn((0, step_logger_1.createStepLogger)(events, this.clock));
|
|
475
|
-
await this.completeStep({
|
|
476
|
-
runId,
|
|
477
|
-
seq: current,
|
|
525
|
+
catch (err) {
|
|
526
|
+
if (attempt >= this.compensationRetries) {
|
|
527
|
+
this.emit({
|
|
528
|
+
type: 'step.failed',
|
|
529
|
+
runId: run.id,
|
|
530
|
+
workflow: run.workflow,
|
|
478
531
|
name,
|
|
479
532
|
kind: 'local',
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
attempts: attempt,
|
|
483
|
-
enqueuedAt: startedAt,
|
|
484
|
-
startedAt,
|
|
533
|
+
error: { message: err instanceof Error ? err.message : String(err) },
|
|
534
|
+
durationMs: Date.now() - startedAt,
|
|
485
535
|
});
|
|
486
|
-
if (options?.compensate)
|
|
487
|
-
compensations.push(options.compensate);
|
|
488
|
-
return output;
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
if (err instanceof errors_1.FatalError || attempt >= maxAttempts) {
|
|
492
|
-
await this.failStep({
|
|
493
|
-
runId,
|
|
494
|
-
seq: current,
|
|
495
|
-
name,
|
|
496
|
-
kind: 'local',
|
|
497
|
-
error: {
|
|
498
|
-
message: err instanceof Error ? err.message : String(err),
|
|
499
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
500
|
-
},
|
|
501
|
-
events,
|
|
502
|
-
attempts: attempt,
|
|
503
|
-
enqueuedAt: startedAt,
|
|
504
|
-
startedAt,
|
|
505
|
-
});
|
|
506
|
-
throw err;
|
|
507
|
-
}
|
|
508
|
-
// Wait out the backoff before the next attempt (no-op when backoffMs is unset).
|
|
509
|
-
const wait = backoffDelay(attempt, options);
|
|
510
|
-
if (wait > 0)
|
|
511
|
-
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
512
536
|
}
|
|
513
537
|
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
// Timer already recorded: resume if due, otherwise re-suspend cheaply.
|
|
521
|
-
if (now >= (existing.wakeAt ?? 0))
|
|
522
|
-
return;
|
|
523
|
-
throw new errors_1.WorkflowSuspended(existing.wakeAt ?? now);
|
|
524
|
-
}
|
|
525
|
-
const wakeAt = now + (0, duration_1.parseDuration)(duration);
|
|
526
|
-
await this.recordTimer(runId, current, 'sleep', wakeAt);
|
|
527
|
-
throw new errors_1.WorkflowSuspended(wakeAt);
|
|
528
|
-
};
|
|
529
|
-
// NOTE (determinism): a bounded wait consumes TWO logical positions (deadline + wait), an
|
|
530
|
-
// unbounded one consumes ONE. So adding or removing `{ timeoutMs }` on an existing
|
|
531
|
-
// `waitForSignal` shifts the seq of every later step — treat it as a workflow-version change
|
|
532
|
-
// (register a new @Workflow version) for runs already in flight.
|
|
533
|
-
const waitForSignal = async (token, opts) => {
|
|
534
|
-
if (opts?.timeoutMs == null) {
|
|
535
|
-
const current = nextSeq();
|
|
536
|
-
const existing = await store.getCheckpoint(runId, current);
|
|
537
|
-
if (existing && existing.status === 'completed')
|
|
538
|
-
return existing.output;
|
|
539
|
-
await store.putSignalWaiter({ token, runId, seq: current });
|
|
540
|
-
throw new errors_1.WorkflowSuspended();
|
|
541
|
-
}
|
|
542
|
-
const timeoutMs = opts.timeoutMs;
|
|
543
|
-
const deadlineSeq = nextSeq();
|
|
544
|
-
const waitSeq = nextSeq();
|
|
545
|
-
// The deadline is recorded durably as a timer checkpoint so replay knows it; the run also gets
|
|
546
|
-
// a run-level wakeAt (via WorkflowSuspended) so the timer poller resumes it at the deadline.
|
|
547
|
-
const recorded = await store.getCheckpoint(runId, deadlineSeq);
|
|
548
|
-
const deadline = recorded?.wakeAt ?? this.clock() + timeoutMs;
|
|
549
|
-
if (!recorded)
|
|
550
|
-
await this.recordTimer(runId, deadlineSeq, `timeout:${token}`, deadline);
|
|
551
|
-
const waited = await store.getCheckpoint(runId, waitSeq);
|
|
552
|
-
if (waited && waited.status === 'completed')
|
|
553
|
-
return waited.output;
|
|
554
|
-
if (this.clock() >= deadline) {
|
|
555
|
-
await store.takeSignalWaiter(token).catch(() => undefined);
|
|
556
|
-
throw new errors_1.SignalTimeoutError(token, timeoutMs);
|
|
557
|
-
}
|
|
558
|
-
await store.putSignalWaiter({ token, runId, seq: waitSeq });
|
|
559
|
-
throw new errors_1.WorkflowSuspended(deadline);
|
|
560
|
-
};
|
|
561
|
-
// An external task = a checkpointed dispatch + a wait for its async-completion `Completion`
|
|
562
|
-
// (delivered by engine.completeTask/failTask). The whole "fire at a foreign system, suspend,
|
|
563
|
-
// resume when it reports back" pattern as one call.
|
|
564
|
-
const task = async (name, dispatch, options) => {
|
|
565
|
-
await step(`task:dispatch:${name}`, dispatch, options);
|
|
566
|
-
return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
|
|
567
|
-
};
|
|
568
|
-
// Child workflow: start it once, then suspend on a `child:<id>` waiter the child signals on its
|
|
569
|
-
// terminal state (see notifyParent). The start is deferred to a microtask so it runs AFTER this
|
|
570
|
-
// run suspends — a fast child can't reentrantly resume a parent that's still running.
|
|
571
|
-
// A breakpoint = a visible `pending` checkpoint + a signal waiter the dashboard resumes via
|
|
572
|
-
// `engine.continue`. Reuses the signal machinery (kind 'signal'), so resume overwrites the
|
|
573
|
-
// pending checkpoint with a completed one and the run replays past it.
|
|
574
|
-
const breakpoint = async (label) => {
|
|
575
|
-
const current = nextSeq();
|
|
576
|
-
const existing = await store.getCheckpoint(runId, current);
|
|
577
|
-
if (existing && existing.status === 'completed')
|
|
578
|
-
return;
|
|
579
|
-
if (!existing) {
|
|
580
|
-
await this.recordBreakpoint(runId, current, label);
|
|
581
|
-
await store.putSignalWaiter({
|
|
582
|
-
token: breakpointToken(runId, current),
|
|
583
|
-
runId,
|
|
584
|
-
seq: current,
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
throw new errors_1.WorkflowSuspended();
|
|
588
|
-
};
|
|
589
|
-
const child = async (workflow, input, childId) => {
|
|
590
|
-
const current = nextSeq();
|
|
591
|
-
const id = childId ?? `${runId}.child.${current}`;
|
|
592
|
-
const existing = await store.getCheckpoint(runId, current);
|
|
593
|
-
if (existing && existing.status === 'completed') {
|
|
594
|
-
return unwrapCompletion(existing.output, `child "${id}"`);
|
|
595
|
-
}
|
|
596
|
-
await store.putSignalWaiter({ token: `child:${id}`, runId, seq: current });
|
|
597
|
-
if (!(await store.getRun(id))) {
|
|
598
|
-
queueMicrotask(() => {
|
|
599
|
-
void this.start(workflow, input, id).catch(() => undefined);
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
throw new errors_1.WorkflowSuspended();
|
|
603
|
-
};
|
|
604
|
-
// Deterministic non-deterministic sources: each is a checkpointed local step, so the value is
|
|
605
|
-
// captured on the first run and replayed verbatim afterwards (a raw Date.now()/Math.random()
|
|
606
|
-
// inside a workflow would differ across replays and corrupt the run).
|
|
607
|
-
const now = () => step('now', async () => this.clock());
|
|
608
|
-
const random = () => step('random', async () => Math.random());
|
|
609
|
-
const uuid = () => step('uuid', async () => globalThis.crypto.randomUUID());
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
|
|
541
|
+
* (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
|
|
542
|
+
* module and the engine stays the orchestrator. */
|
|
543
|
+
get ctxHost() {
|
|
610
544
|
return {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
call: (remote, input) => this.callRemote(runId, nextSeq(), remote, input),
|
|
545
|
+
store: this.store,
|
|
546
|
+
clock: this.clock,
|
|
547
|
+
webhookUrl: this.webhookUrl,
|
|
548
|
+
completeStep: (s) => this.completeStep(s),
|
|
549
|
+
failStep: (s) => this.failStep(s),
|
|
550
|
+
callRemote: (runId, seq, step, input, queue, transport) => this.callRemote(runId, seq, step, input, queue, transport),
|
|
551
|
+
// Defer so a fast child can't reentrantly resume a still-running parent.
|
|
552
|
+
startChild: (workflow, input, id) => {
|
|
553
|
+
queueMicrotask(() => void this.start(workflow, input, id).catch(() => undefined));
|
|
554
|
+
},
|
|
622
555
|
};
|
|
623
556
|
}
|
|
624
|
-
/** Persist a completed timer checkpoint (a durable sleep / signal deadline) at a logical position. */
|
|
625
|
-
async recordTimer(runId, seq, name, wakeAt) {
|
|
626
|
-
const at = new Date();
|
|
627
|
-
await this.store.saveCheckpoint({
|
|
628
|
-
runId,
|
|
629
|
-
seq,
|
|
630
|
-
name,
|
|
631
|
-
kind: 'sleep',
|
|
632
|
-
stepId: (0, protocol_1.stepId)(runId, seq),
|
|
633
|
-
status: 'completed',
|
|
634
|
-
wakeAt,
|
|
635
|
-
attempts: 1,
|
|
636
|
-
enqueuedAt: at,
|
|
637
|
-
startedAt: at,
|
|
638
|
-
finishedAt: at,
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
/** Persist a `pending` checkpoint marking a breakpoint, so it's visible (and resumable) in the UI. */
|
|
642
|
-
async recordBreakpoint(runId, seq, label) {
|
|
643
|
-
const at = new Date();
|
|
644
|
-
await this.store.saveCheckpoint({
|
|
645
|
-
runId,
|
|
646
|
-
seq,
|
|
647
|
-
name: label ? `${BREAKPOINT}:${label}` : BREAKPOINT,
|
|
648
|
-
kind: 'signal',
|
|
649
|
-
stepId: (0, protocol_1.stepId)(runId, seq),
|
|
650
|
-
status: 'pending',
|
|
651
|
-
attempts: 1,
|
|
652
|
-
enqueuedAt: at,
|
|
653
|
-
startedAt: at,
|
|
654
|
-
finishedAt: at,
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
557
|
/**
|
|
658
558
|
* Resume a run paused at a {@link WorkflowCtx.breakpoint} (e.g. the dashboard "continue" button).
|
|
659
559
|
* Finds the run's pending breakpoint checkpoint and signals it. Returns null if the run isn't
|
|
@@ -664,29 +564,103 @@ class WorkflowEngine {
|
|
|
664
564
|
const bp = checkpoints.find(isBreakpoint);
|
|
665
565
|
if (!bp)
|
|
666
566
|
return null;
|
|
667
|
-
return this.signal(breakpointToken(runId, bp.seq), undefined);
|
|
567
|
+
return this.signal((0, protocol_1.breakpointToken)(runId, bp.seq), undefined);
|
|
668
568
|
}
|
|
669
|
-
|
|
569
|
+
/**
|
|
570
|
+
* Read the latest value a run published for `key` via {@link WorkflowCtx.setEvent} — a
|
|
571
|
+
* side-effect-free query of a live (or finished) run's state. Returns `undefined` if the run
|
|
572
|
+
* never published that key. The suspend-model counterpart of a Temporal query.
|
|
573
|
+
*/
|
|
574
|
+
async getEvent(runId, key) {
|
|
575
|
+
const name = `event:${key}`;
|
|
576
|
+
const checkpoints = await this.store.listCheckpoints(runId);
|
|
577
|
+
// listCheckpoints is ordered by seq ascending, so the last match is the most recent value.
|
|
578
|
+
let latest;
|
|
579
|
+
for (const cp of checkpoints)
|
|
580
|
+
if (cp.name === name)
|
|
581
|
+
latest = cp.output;
|
|
582
|
+
return latest;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Register a validator gating `engine.update(runId, name, …)` for runs of `workflow`. The
|
|
586
|
+
* validator runs BEFORE the update is delivered, so a rejection leaves the run untouched. One
|
|
587
|
+
* validator per (workflow, update name); registering again replaces it.
|
|
588
|
+
*/
|
|
589
|
+
registerUpdateValidator(workflow, name, validate) {
|
|
590
|
+
this.updateValidators.set(`${workflow}:${name}`, validate);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Deliver a validated update to the run waiting at `ctx.onUpdate(name)`. Runs the registered
|
|
594
|
+
* validator (if any) first: on rejection returns `{ accepted: false, reason }` without disturbing
|
|
595
|
+
* the run; otherwise delivers `arg` and resumes, returning `{ accepted: true, run }` (`run` is null
|
|
596
|
+
* if nothing was waiting — a too-early or duplicate update).
|
|
597
|
+
*/
|
|
598
|
+
async update(runId, name, arg) {
|
|
599
|
+
const run = await this.store.getRun(runId);
|
|
600
|
+
if (!run)
|
|
601
|
+
return { accepted: false, reason: `run ${runId} not found` };
|
|
602
|
+
const validate = this.updateValidators.get(`${run.workflow}:${name}`);
|
|
603
|
+
if (validate) {
|
|
604
|
+
try {
|
|
605
|
+
const reason = await validate(arg);
|
|
606
|
+
if (typeof reason === 'string' && reason.length > 0)
|
|
607
|
+
return { accepted: false, reason };
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
return { accepted: false, reason: err instanceof Error ? err.message : String(err) };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const result = await this.signal(`update:${runId}:${name}`, arg);
|
|
614
|
+
return { accepted: true, run: result };
|
|
615
|
+
}
|
|
616
|
+
async callRemote(runId, seq, step, input, queue, transport) {
|
|
670
617
|
const existing = await this.store.getCheckpoint(runId, seq);
|
|
671
618
|
if (existing && existing.name !== step.name) {
|
|
672
619
|
throw new errors_1.NonDeterminismError(runId, seq, step.name, existing.name);
|
|
673
620
|
}
|
|
674
621
|
if (existing?.status === 'completed')
|
|
675
622
|
return existing.output;
|
|
676
|
-
if (
|
|
677
|
-
throw new RemoteStepError(existing.error);
|
|
678
|
-
if (!this.transport)
|
|
623
|
+
if (this.pool.size === 0)
|
|
679
624
|
throw new Error('remote steps require a Transport');
|
|
680
625
|
// A step with a liveness `timeoutMs` keeps the in-memory await + heartbeat path (re-dispatch a
|
|
681
626
|
// presumed-dead worker). Without one, the call SUSPENDS DURABLY: dispatch, persist a `pending`
|
|
682
627
|
// checkpoint, and let the result resume the run on whichever instance receives it — so a worker
|
|
683
628
|
// pod can scale down or crash mid-step without losing the run or re-running completed work.
|
|
684
629
|
if (step.timeoutMs)
|
|
685
|
-
return this.callRemoteInMemory(runId, seq, step, input);
|
|
630
|
+
return this.callRemoteInMemory(runId, seq, step, input, transport);
|
|
686
631
|
if (existing?.status === 'pending')
|
|
687
632
|
throw new errors_1.WorkflowSuspended(); // dispatched; keep waiting
|
|
688
|
-
|
|
633
|
+
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|
|
634
|
+
// unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
|
|
635
|
+
// The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
|
|
636
|
+
// first time we see it, so it's stable across replays and survives a crash.
|
|
637
|
+
let attempt = 1;
|
|
638
|
+
if (existing?.status === 'failed') {
|
|
639
|
+
const maxAttempts = Math.max(1, step.retries ?? 1);
|
|
640
|
+
const retryable = existing.error?.retryable !== false;
|
|
641
|
+
if (!retryable || existing.attempts >= maxAttempts)
|
|
642
|
+
throw new RemoteStepError(existing.error);
|
|
643
|
+
if (existing.wakeAt == null) {
|
|
644
|
+
const wakeAt = this.clock() + (0, backoff_1.backoffDelay)(existing.attempts, step);
|
|
645
|
+
await this.store.saveCheckpoint({ ...existing, wakeAt });
|
|
646
|
+
throw new errors_1.WorkflowSuspended(wakeAt);
|
|
647
|
+
}
|
|
648
|
+
if (this.clock() < existing.wakeAt)
|
|
649
|
+
throw new errors_1.WorkflowSuspended(existing.wakeAt);
|
|
650
|
+
attempt = existing.attempts + 1;
|
|
651
|
+
}
|
|
689
652
|
const id = (0, protocol_1.stepId)(runId, seq);
|
|
653
|
+
// Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
|
|
654
|
+
// run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
|
|
655
|
+
// the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
|
|
656
|
+
const controller = queue ? this.queues.get(queue) : undefined;
|
|
657
|
+
if (controller) {
|
|
658
|
+
const admission = controller.tryAdmit();
|
|
659
|
+
if (!admission.ok)
|
|
660
|
+
throw new errors_1.WorkflowSuspended(admission.retryAt);
|
|
661
|
+
this.stepQueue.set(id, queue);
|
|
662
|
+
}
|
|
663
|
+
const validInput = step.input.parse(input);
|
|
690
664
|
const enqueuedAt = new Date();
|
|
691
665
|
// Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
|
|
692
666
|
await this.store.saveCheckpoint({
|
|
@@ -697,21 +671,22 @@ class WorkflowEngine {
|
|
|
697
671
|
stepId: id,
|
|
698
672
|
status: 'pending',
|
|
699
673
|
input: validInput,
|
|
700
|
-
attempts:
|
|
674
|
+
attempts: attempt,
|
|
701
675
|
workerGroup: step.group,
|
|
702
676
|
enqueuedAt,
|
|
703
677
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
704
678
|
finishedAt: enqueuedAt,
|
|
705
679
|
});
|
|
706
|
-
await this.
|
|
680
|
+
await this.pool.dispatch({
|
|
707
681
|
runId,
|
|
708
682
|
seq,
|
|
709
683
|
name: step.name,
|
|
710
684
|
stepId: id,
|
|
711
685
|
group: step.group,
|
|
712
686
|
input: validInput,
|
|
713
|
-
|
|
714
|
-
|
|
687
|
+
traceparent: this.traceparent?.(),
|
|
688
|
+
attempt,
|
|
689
|
+
}, transport);
|
|
715
690
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
716
691
|
throw new errors_1.WorkflowSuspended();
|
|
717
692
|
}
|
|
@@ -724,6 +699,9 @@ class WorkflowEngine {
|
|
|
724
699
|
const cp = await this.store.getCheckpoint(result.runId, result.seq);
|
|
725
700
|
if (!cp || cp.status !== 'pending')
|
|
726
701
|
return;
|
|
702
|
+
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
|
|
703
|
+
// before the cancelled-run early-return below, so a cancellation can't leak the slot.
|
|
704
|
+
this.releaseQueueSlot(cp.stepId);
|
|
727
705
|
// Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
|
|
728
706
|
// or resume (the run is already terminal). This is the engine side of cooperative cancellation.
|
|
729
707
|
const run = await this.store.getRun(result.runId);
|
|
@@ -753,9 +731,17 @@ class WorkflowEngine {
|
|
|
753
731
|
});
|
|
754
732
|
await this.resume(result.runId);
|
|
755
733
|
}
|
|
734
|
+
/** Release the flow-control slot a dispatched step held (if any), by its stepId. */
|
|
735
|
+
releaseQueueSlot(id) {
|
|
736
|
+
const queue = this.stepQueue.get(id);
|
|
737
|
+
if (queue === undefined)
|
|
738
|
+
return;
|
|
739
|
+
this.stepQueue.delete(id);
|
|
740
|
+
this.queues.get(queue)?.release();
|
|
741
|
+
}
|
|
756
742
|
/** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
|
|
757
|
-
async callRemoteInMemory(runId, seq, step, input) {
|
|
758
|
-
if (
|
|
743
|
+
async callRemoteInMemory(runId, seq, step, input, transport) {
|
|
744
|
+
if (this.pool.size === 0)
|
|
759
745
|
throw new Error('remote steps require a Transport');
|
|
760
746
|
const validInput = step.input.parse(input);
|
|
761
747
|
const id = (0, protocol_1.stepId)(runId, seq);
|
|
@@ -771,15 +757,16 @@ class WorkflowEngine {
|
|
|
771
757
|
const resultPromise = new Promise((resolve, reject) => {
|
|
772
758
|
this.pending.set(id, { resolve, reject });
|
|
773
759
|
});
|
|
774
|
-
await this.
|
|
760
|
+
await this.pool.dispatch({
|
|
775
761
|
runId,
|
|
776
762
|
seq,
|
|
777
763
|
name: step.name,
|
|
778
764
|
stepId: id,
|
|
779
765
|
group: step.group,
|
|
780
766
|
input: validInput,
|
|
767
|
+
traceparent: this.traceparent?.(),
|
|
781
768
|
attempt,
|
|
782
|
-
});
|
|
769
|
+
}, transport);
|
|
783
770
|
try {
|
|
784
771
|
const resolution = step.timeoutMs
|
|
785
772
|
? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)
|