@webda/workflow 0.0.0-stage → 4.0.0-beta.13

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.
Files changed (48) hide show
  1. package/LICENSE +166 -0
  2. package/README.md +54 -2
  3. package/lib/authoring.d.ts +112 -0
  4. package/lib/authoring.d.ts.map +1 -0
  5. package/lib/authoring.js +107 -0
  6. package/lib/authoring.js.map +1 -0
  7. package/lib/expression.d.ts +21 -0
  8. package/lib/expression.d.ts.map +1 -0
  9. package/lib/expression.js +196 -0
  10. package/lib/expression.js.map +1 -0
  11. package/lib/graph.d.ts +291 -0
  12. package/lib/graph.d.ts.map +1 -0
  13. package/lib/graph.js +72 -0
  14. package/lib/graph.js.map +1 -0
  15. package/lib/hooks.d.ts +38 -0
  16. package/lib/hooks.d.ts.map +1 -0
  17. package/lib/hooks.js +23 -0
  18. package/lib/hooks.js.map +1 -0
  19. package/lib/index.d.ts +11 -0
  20. package/lib/index.d.ts.map +1 -0
  21. package/lib/index.js +15 -0
  22. package/lib/index.js.map +1 -0
  23. package/lib/interpreter.d.ts +63 -0
  24. package/lib/interpreter.d.ts.map +1 -0
  25. package/lib/interpreter.js +330 -0
  26. package/lib/interpreter.js.map +1 -0
  27. package/lib/models/workflow.model.d.ts +71 -0
  28. package/lib/models/workflow.model.d.ts.map +1 -0
  29. package/lib/models/workflow.model.js +70 -0
  30. package/lib/models/workflow.model.js.map +1 -0
  31. package/lib/models/workflowinstance.model.d.ts +132 -0
  32. package/lib/models/workflowinstance.model.d.ts.map +1 -0
  33. package/lib/models/workflowinstance.model.js +168 -0
  34. package/lib/models/workflowinstance.model.js.map +1 -0
  35. package/lib/models/workflowsignal.model.d.ts +51 -0
  36. package/lib/models/workflowsignal.model.d.ts.map +1 -0
  37. package/lib/models/workflowsignal.model.js +53 -0
  38. package/lib/models/workflowsignal.model.js.map +1 -0
  39. package/lib/models/workflowtask.model.d.ts +103 -0
  40. package/lib/models/workflowtask.model.d.ts.map +1 -0
  41. package/lib/models/workflowtask.model.js +104 -0
  42. package/lib/models/workflowtask.model.js.map +1 -0
  43. package/lib/workflow.service.d.ts +670 -0
  44. package/lib/workflow.service.d.ts.map +1 -0
  45. package/lib/workflow.service.js +1950 -0
  46. package/lib/workflow.service.js.map +1 -0
  47. package/package.json +68 -3
  48. package/webda.module.json +2670 -0
@@ -0,0 +1,1950 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ import { escape } from "@webda/ql";
36
+ import { callOperation, canCallOperation, CronService, hasSchema, Operation, listFullOperations, registerOperation, registerSchema, runAsSystem, Service, ServiceParameters, Session, SimpleOperationContext, useApplication, useContext, useCore, useDynamicService, useInstanceStorage, useSystem, WebdaError } from "@webda/core";
37
+ import { Duration } from "@webda/utils";
38
+ import { createHash, randomUUID } from "node:crypto";
39
+ import { BUILTINS, flowVersion } from "./graph.js";
40
+ import { keysOf, run, statementIdOf } from "./interpreter.js";
41
+ import { setWorkflowControls } from "./hooks.js";
42
+ import { WorkflowInstance } from "./models/workflowinstance.model.js";
43
+ import { WorkflowTask } from "./models/workflowtask.model.js";
44
+ import { WorkflowSignal } from "./models/workflowsignal.model.js";
45
+ import { Workflow as StoredWorkflow } from "./models/workflow.model.js";
46
+ import { validateExpr } from "./expression.js";
47
+ import { useRepository } from "@webda/models";
48
+ /**
49
+ * Output schema of every flow launch operation
50
+ */
51
+ export const LAUNCH_OUTPUT_SCHEMA = "Webda/WorkflowLaunch.output";
52
+ /**
53
+ * Status of a task error that is never retried: the same call would fail the same way
54
+ */
55
+ const NOT_RETRYABLE_STATUS = {
56
+ 400: "BadRequest",
57
+ 401: "Unauthorized",
58
+ 403: "Forbidden",
59
+ 404: "NotFound"
60
+ };
61
+ /**
62
+ * An error of a task, as the engine reports it in the flow
63
+ */
64
+ class TaskError extends Error {
65
+ code;
66
+ retryable;
67
+ /**
68
+ * @param code - error code
69
+ * @param message - error message
70
+ * @param retryable - whether another attempt may succeed
71
+ */
72
+ constructor(code, message, retryable = true) {
73
+ super(message);
74
+ this.code = code;
75
+ this.retryable = retryable;
76
+ }
77
+ }
78
+ /**
79
+ * Statuses of a task row that will still produce an outcome
80
+ */
81
+ const LIVE_TASK = ["PENDING", "RUNNING", "WAITING_EVENT", "WAITING_CHILD"];
82
+ /**
83
+ * `runAt` of a wait without timeout (JSON has no Infinity)
84
+ */
85
+ export const NO_TIMEOUT = Number.MAX_SAFE_INTEGER;
86
+ /**
87
+ * Built-ins an instance waits on: when all its pending tasks are of these, it is WAITING
88
+ */
89
+ const WAITING_OPERATIONS = [BUILTINS.Sleep, BUILTINS.WaitForEvent];
90
+ /**
91
+ * Statement kinds of a flow graph, with their expression fields
92
+ */
93
+ const STATEMENT_EXPRESSIONS = {
94
+ task: ["input"],
95
+ assign: ["value"],
96
+ if: ["cond"],
97
+ forOf: ["items"],
98
+ while: ["cond"],
99
+ try: [],
100
+ parallel: [],
101
+ map: ["items"],
102
+ return: ["value"],
103
+ throw: ["value"]
104
+ };
105
+ /**
106
+ * Operation id syntax
107
+ */
108
+ const OPERATION_ID = /^([A-Z][A-Za-z0-9]*\.)*([A-Z][a-zA-Z0-9]*)$/;
109
+ /**
110
+ * Whether an object is an instance of a class with this name, its own or an ancestor
111
+ * @param object - the object
112
+ * @param className - the class name
113
+ * @returns true when a constructor of its prototype chain has this name
114
+ */
115
+ function isInstanceOfClass(object, className) {
116
+ for (let proto = object ? Object.getPrototypeOf(object) : null; proto; proto = Object.getPrototypeOf(proto)) {
117
+ if (proto.constructor?.name === className)
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ /**
123
+ * Abort reason of an attempt whose outcome must not be written: its lease was lost, or the service stopped
124
+ */
125
+ const DROPPED = "WorkflowAttemptDropped";
126
+ /**
127
+ * Abort signal of the current workflow task attempt
128
+ *
129
+ * It is aborted on timeout, when the worker loses the task lease, and when the service stops. An operation run by a
130
+ * workflow should observe it to stop early: an attempt that keeps running is ignored, and the task may run again
131
+ * (operations are assumed idempotent).
132
+ * @returns the signal, undefined outside a workflow task
133
+ */
134
+ export function useAbortSignal() {
135
+ try {
136
+ return useContext()?.getExtension?.("abortSignal");
137
+ }
138
+ catch {
139
+ // A context without extensions (the global context)
140
+ return undefined;
141
+ }
142
+ }
143
+ /**
144
+ * Parameters of the {@link WorkflowService}
145
+ */
146
+ export class WorkflowServiceParameters extends ServiceParameters {
147
+ /**
148
+ * Interval between two polls of the worker (ms)
149
+ * @default 1000
150
+ */
151
+ pollInterval;
152
+ /**
153
+ * Maximum number of tasks running at once on this replica
154
+ * @default 10
155
+ */
156
+ concurrency;
157
+ /**
158
+ * Lease of a claimed task (ms), renewed while it runs
159
+ * @default 60000
160
+ */
161
+ leaseDuration;
162
+ /**
163
+ * Maximum size of the journal and input of an instance (bytes of JSON)
164
+ * @default 262144
165
+ */
166
+ maxContextSize;
167
+ /**
168
+ * Run the worker loop on this replica
169
+ * @default true
170
+ */
171
+ worker;
172
+ /**
173
+ * Allow `useSystem` tasks in stored workflows
174
+ * @default false
175
+ */
176
+ allowStoredSystem;
177
+ /**
178
+ * How long a signal sent before any wait is kept (Duration syntax)
179
+ * @default "1d"
180
+ */
181
+ signalTtl;
182
+ /**
183
+ * Interval of the recovery sweep (ms): an active instance not updated for this long and without a live task is
184
+ * replayed
185
+ * @default 60000
186
+ */
187
+ sweepInterval;
188
+ /**
189
+ * Grace period of stop() for the running attempts (ms), then they are aborted and their outcome dropped
190
+ * @default 30000
191
+ */
192
+ stopTimeout;
193
+ /**
194
+ * Flows to register
195
+ */
196
+ flows;
197
+ /**
198
+ * Maximum size of a signal payload (bytes of JSON)
199
+ * @default 65536
200
+ */
201
+ maxSignalPayload;
202
+ /**
203
+ * Maximum number of unexpired buffered signals per `(event, key)`: a new one past it is refused
204
+ * @default 100
205
+ */
206
+ maxBufferedSignals;
207
+ /**
208
+ * Role needed to create, update or delete a stored workflow
209
+ * @default "workflow-admin"
210
+ */
211
+ storedWorkflowRole;
212
+ /**
213
+ * PubSub service announcing the stored workflow changes to the other replicas
214
+ */
215
+ syncPubSub;
216
+ /**
217
+ * Without `syncPubSub`, interval between two reloads of the stored workflows (ms)
218
+ * @default 60000
219
+ */
220
+ syncInterval;
221
+ /**
222
+ * @override
223
+ * @param params - the input parameters
224
+ * @returns this
225
+ */
226
+ load(params = {}) {
227
+ super.load(params);
228
+ this.pollInterval ??= 1000;
229
+ this.concurrency ??= 10;
230
+ this.leaseDuration ??= 60000;
231
+ this.maxContextSize ??= 262144;
232
+ this.worker ??= true;
233
+ this.allowStoredSystem ??= false;
234
+ this.sweepInterval ??= 60000;
235
+ this.stopTimeout ??= 30000;
236
+ this.signalTtl ??= "1d";
237
+ this.storedWorkflowRole ??= "workflow-admin";
238
+ this.maxSignalPayload ??= 65536;
239
+ this.maxBufferedSignals ??= 100;
240
+ this.syncInterval ??= 60000;
241
+ return this;
242
+ }
243
+ }
244
+ /**
245
+ * Whether an instance still runs
246
+ * @param instance - the instance
247
+ * @returns true when RUNNING or WAITING
248
+ */
249
+ function isActive(instance) {
250
+ return instance?.status === "RUNNING" || instance?.status === "WAITING";
251
+ }
252
+ /**
253
+ * Whether two flow errors are the same
254
+ * @param a - an error
255
+ * @param b - another error
256
+ * @returns true when code, message and operation match
257
+ */
258
+ function sameError(a, b) {
259
+ return !!a && !!b && a.code === b.code && a.message === b.message && a.operation === b.operation;
260
+ }
261
+ /**
262
+ * Uuid of the n-th row of a task key: the same for every worker, so two advances cannot create it twice
263
+ * @param instance - instance uuid
264
+ * @param key - task key
265
+ * @param index - row index of the key, from 1
266
+ * @returns a uuid
267
+ */
268
+ function taskUuid(instance, key, index) {
269
+ const h = createHash("sha256").update(`${instance}\n${key}\n${index}`).digest("hex");
270
+ return `${h.substring(0, 8)}-${h.substring(8, 12)}-5${h.substring(13, 16)}-a${h.substring(17, 20)}-${h.substring(20, 32)}`;
271
+ }
272
+ /**
273
+ * Runs durable workflows: registers each flow as an operation, launches instances and runs their tasks
274
+ *
275
+ * Every replica runs the worker loop: a task is claimed with a lease (a conditional patch on `leaseUntil`), so it
276
+ * runs on one replica at a time. Its outcome goes to the instance journal (a conditional patch on `revision`), then
277
+ * the flow is replayed to find the next tasks.
278
+ *
279
+ * @WebdaModda WorkflowService
280
+ */
281
+ let WorkflowService = (() => {
282
+ let _classSuper = Service;
283
+ let _instanceExtraInitializers = [];
284
+ let _signal_decorators;
285
+ return class WorkflowService extends _classSuper {
286
+ static {
287
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
288
+ _signal_decorators = [Operation({
289
+ id: "Workflow.Signal",
290
+ rest: { method: "post", path: "workflows/signal" },
291
+ graphql: false,
292
+ grpc: false
293
+ })];
294
+ __esDecorate(this, null, _signal_decorators, { kind: "method", name: "signal", static: false, private: false, access: { has: obj => "signal" in obj, get: obj => obj.signal }, metadata: _metadata }, null, _instanceExtraInitializers);
295
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
296
+ }
297
+ /**
298
+ * Flows registered on this service, by flow id
299
+ */
300
+ flows = (__runInitializers(this, _instanceExtraInitializers), new Map());
301
+ /**
302
+ * Every version registered of each flow, for migrate
303
+ */
304
+ versions = new Map();
305
+ /**
306
+ * Flows of the modules (compiled `.flow.ts`) registered by this service
307
+ */
308
+ moduleFlows = new Set();
309
+ /**
310
+ * Worker id, the lease owner of the tasks it claims
311
+ */
312
+ workerId = randomUUID();
313
+ /**
314
+ * Attempts running on this replica, by task uuid
315
+ */
316
+ running = new Map();
317
+ /**
318
+ * Tasks reclaimed after too many crashes: they fail without running
319
+ */
320
+ crashed = new WeakSet();
321
+ /**
322
+ * The service is stopping: nothing more is claimed
323
+ */
324
+ stopping = false;
325
+ /**
326
+ * Poll in progress
327
+ */
328
+ pollPromise;
329
+ /**
330
+ * Last recovery sweep (ms)
331
+ */
332
+ lastSweep = 0;
333
+ /**
334
+ * Executions started by the worker loop
335
+ */
336
+ executions = new Set();
337
+ /**
338
+ * Worker loop timer
339
+ */
340
+ timer;
341
+ /**
342
+ * Stored workflows registered here, by workflow uuid
343
+ */
344
+ storedFlows = new Map();
345
+ /**
346
+ * Stored workflows reload timer
347
+ */
348
+ syncTimer;
349
+ /**
350
+ * Subscription to the stored workflow changes
351
+ */
352
+ syncConsumer;
353
+ /**
354
+ * @override
355
+ */
356
+ resolve() {
357
+ super.resolve();
358
+ // The configured service serves the WorkflowInstance actions
359
+ setWorkflowControls(this);
360
+ // Before the transports init: they snapshot the operations
361
+ for (const def of this.parameters.flows ?? []) {
362
+ this.registerFlow({ ...def, version: def.version ?? "", source: def.source ?? "code" });
363
+ }
364
+ this.registerModuleFlows();
365
+ return this;
366
+ }
367
+ /**
368
+ * Register the flows compiled from the `.flow.ts` files (`flows` of the modules), with their crons
369
+ *
370
+ * The first WorkflowService to resolve registers them; the typed calls are resolved in {@link init}, once every
371
+ * service registered its operations.
372
+ */
373
+ registerModuleFlows() {
374
+ const flows = useApplication().getModules()?.flows ?? {};
375
+ for (const [id, entry] of Object.entries(flows)) {
376
+ const operation = listFullOperations()[id];
377
+ if (operation?.context?.flowId === id && operation.service !== this.getName())
378
+ continue;
379
+ if (operation && !operation.context?.flowId) {
380
+ throw new WebdaError.FatalInitError(`Flow ${id}: its operation id is used by the operation of service ${operation.service}`);
381
+ }
382
+ const { crons, ...def } = structuredClone(entry);
383
+ // The compiled version is kept: the build hashed the graph before its targets are resolved
384
+ this.registerFlow({ ...def, source: "code" });
385
+ this.moduleFlows.add(id);
386
+ for (const cron of crons ?? []) {
387
+ CronService.register(id, cron);
388
+ }
389
+ }
390
+ }
391
+ /**
392
+ * The operation a typed call (`target: { class, method }`) runs: the operation of the configured service whose
393
+ * instance is of that class (matched by constructor name, the class or an ancestor)
394
+ * @param target - the target of the task
395
+ * @param target.class - the service class name
396
+ * @param target.method - the method name
397
+ * @returns the operation id, or why there is none: no service (missing) or several (ambiguous)
398
+ */
399
+ resolveTarget(target) {
400
+ const { class: className, method } = target ?? {};
401
+ const operations = Object.entries(listFullOperations());
402
+ const matches = Object.entries(useCore().getServices())
403
+ .filter(([, service]) => isInstanceOfClass(service, className))
404
+ .flatMap(([name]) => operations.filter(([, op]) => op.service === name && op.method === method).map(([opId]) => ({ name, opId })));
405
+ if (matches.length === 1)
406
+ return { operation: matches[0].opId };
407
+ return matches.length
408
+ ? {
409
+ ambiguous: `several configured services expose ${className}.${method}: ${matches.map(m => m.name).join(", ")}`
410
+ }
411
+ : { missing: `no configured service of class ${className} exposes ${method} as an operation` };
412
+ }
413
+ /**
414
+ * Resolve the typed calls of a flow body in place
415
+ * @param body - the statements
416
+ * @returns the problems, by statement id
417
+ */
418
+ resolveTargets(body) {
419
+ const problems = { missing: [], ambiguous: [] };
420
+ const walk = (stmts) => {
421
+ for (const stmt of stmts ?? []) {
422
+ for (const key of ["then", "else", "body", "catch", "finally"])
423
+ walk(stmt[key]);
424
+ (stmt.branches ?? []).forEach(walk);
425
+ if (stmt.kind !== "task" || !stmt.target)
426
+ continue;
427
+ const res = this.resolveTarget(stmt.target);
428
+ if (res.operation)
429
+ stmt.operation = res.operation;
430
+ else if (res.ambiguous)
431
+ problems.ambiguous.push(`${stmt.id}: ${res.ambiguous}`);
432
+ else
433
+ problems.missing.push(`${stmt.id}: ${res.missing}`);
434
+ }
435
+ };
436
+ walk(body);
437
+ return problems;
438
+ }
439
+ /**
440
+ * Point every typed call of the compiled flows at the operation of the configured service of its class
441
+ *
442
+ * A flow whose service is not configured (a packaged unit without it) is removed with its crons; a call that several
443
+ * services serve, or a flow whose operation was taken over, stops the startup.
444
+ * @throws FatalInitError on an ambiguous call, or a flow id taken by another operation
445
+ */
446
+ resolveModuleTargets() {
447
+ for (const id of [...this.moduleFlows]) {
448
+ const operation = listFullOperations()[id];
449
+ if (operation?.context?.flowId !== id || operation.service !== this.getName()) {
450
+ throw new WebdaError.FatalInitError(`Flow ${id}: its operation id is used by the operation of service ${operation?.service}`);
451
+ }
452
+ const flow = this.flows.get(id);
453
+ const { missing, ambiguous } = this.resolveTargets(flow.body);
454
+ if (ambiguous.length) {
455
+ throw new WebdaError.FatalInitError(`Flow ${id} calls an ambiguous operation: ${ambiguous.join("; ")}`);
456
+ }
457
+ if (missing.length) {
458
+ this.log("ERROR", `Flow ${id} is not registered: ${missing.join("; ")}`);
459
+ this.unregisterModuleFlow(id);
460
+ }
461
+ }
462
+ }
463
+ /**
464
+ * Remove a module flow: its operation and its crons
465
+ * @param id - the flow id
466
+ */
467
+ unregisterModuleFlow(id) {
468
+ this.moduleFlows.delete(id);
469
+ this.flows.delete(id);
470
+ this.versions.delete(id);
471
+ CronService.unregister(id);
472
+ const operation = useInstanceStorage().operations?.[id];
473
+ if (operation?.service === this.getName() && operation.context?.flowId === id) {
474
+ delete useInstanceStorage().operations[id];
475
+ }
476
+ }
477
+ /**
478
+ * @override
479
+ */
480
+ async init() {
481
+ await super.init();
482
+ this.resolveModuleTargets();
483
+ this.listenStored();
484
+ await this.reloadStored();
485
+ this.startSync();
486
+ if (this.parameters.worker) {
487
+ this.startWorker();
488
+ }
489
+ return this;
490
+ }
491
+ /**
492
+ * Stop the worker loop
493
+ *
494
+ * Running attempts get `stopTimeout` to finish, then they are aborted and their outcome dropped: their lease
495
+ * expires and another worker runs them again.
496
+ * @override
497
+ */
498
+ async stop() {
499
+ this.stopping = true;
500
+ if (this.timer) {
501
+ clearInterval(this.timer);
502
+ this.timer = undefined;
503
+ }
504
+ if (this.syncTimer) {
505
+ clearInterval(this.syncTimer);
506
+ this.syncTimer = undefined;
507
+ }
508
+ await this.syncConsumer?.cancel();
509
+ this.syncConsumer = undefined;
510
+ // A poll in flight may still start executions
511
+ await this.pollPromise;
512
+ let timer;
513
+ const grace = new Promise(resolve => {
514
+ timer = setTimeout(resolve, this.parameters.stopTimeout);
515
+ });
516
+ await Promise.race([Promise.allSettled([...this.executions]), grace]);
517
+ clearTimeout(timer);
518
+ for (const attempt of this.running.values()) {
519
+ attempt.controller.abort(DROPPED);
520
+ }
521
+ await super.stop();
522
+ }
523
+ /**
524
+ * Register a schema in the validator and the application
525
+ * @param name - schema name
526
+ * @param schema - the JSON schema
527
+ */
528
+ registerSchema(name, schema) {
529
+ if (!hasSchema(name)) {
530
+ registerSchema(name, schema);
531
+ }
532
+ useApplication().getSchemas()[name] = schema;
533
+ }
534
+ /**
535
+ * Register a flow as an operation `<flow id>` served by {@link launch}
536
+ * @param def - the flow definition
537
+ */
538
+ registerFlow(def) {
539
+ const flow = { ...def, version: def.version || flowVersion(def) };
540
+ this.flows.set(flow.id, flow);
541
+ if (!this.versions.has(flow.id))
542
+ this.versions.set(flow.id, new Map());
543
+ this.versions.get(flow.id).set(flow.version, flow);
544
+ // Core cannot remove a schema: a stored flow, which changes at runtime, has one per version
545
+ const input = flow.source === "stored" ? `${flow.id}.input.${flow.version}` : `${flow.id}.input`;
546
+ // The parameters are given to launch in this order
547
+ this.registerSchema(input, {
548
+ type: "object",
549
+ ...flow.input,
550
+ properties: flow.input?.properties ?? {},
551
+ "x-webda-arguments": flow.params
552
+ });
553
+ this.registerSchema(LAUNCH_OUTPUT_SCHEMA, {
554
+ type: "object",
555
+ properties: { uuid: { type: "string" } },
556
+ required: ["uuid"]
557
+ });
558
+ registerOperation(flow.id, {
559
+ service: this.getName(),
560
+ method: "launch",
561
+ input,
562
+ output: LAUNCH_OUTPUT_SCHEMA,
563
+ context: { flowId: flow.id },
564
+ graphql: false,
565
+ grpc: false
566
+ });
567
+ }
568
+ /**
569
+ * Get a registered flow
570
+ * @param flowId - the flow id
571
+ * @returns the definition
572
+ */
573
+ getFlow(flowId) {
574
+ return this.flows.get(flowId);
575
+ }
576
+ /**
577
+ * Find a flow registered on any WorkflowService, by its operation
578
+ * @param flowId - the flow id
579
+ * @returns the current definition, undefined when the operation is not a flow
580
+ */
581
+ resolveFlow(flowId) {
582
+ if (!flowId)
583
+ return undefined;
584
+ const local = this.flows.get(flowId);
585
+ if (local)
586
+ return local;
587
+ const operation = listFullOperations()[flowId];
588
+ if (operation?.context?.flowId !== flowId || !operation.service)
589
+ return undefined;
590
+ const service = useDynamicService(operation.service);
591
+ return typeof service?.getFlow === "function" ? service.getFlow(flowId) : undefined;
592
+ }
593
+ /**
594
+ * Find a version of a flow
595
+ * @param flowId - the flow id
596
+ * @param version - the version
597
+ * @returns the definition
598
+ */
599
+ getFlowVersion(flowId, version) {
600
+ const local = this.versions.get(flowId)?.get(version);
601
+ if (local)
602
+ return local;
603
+ const operation = listFullOperations()[flowId];
604
+ const service = operation?.service ? useDynamicService(operation.service) : undefined;
605
+ return service !== this && typeof service?.getFlowVersion === "function"
606
+ ? service.getFlowVersion(flowId, version)
607
+ : undefined;
608
+ }
609
+ /**
610
+ * Role needed to write a stored workflow
611
+ * @returns the role
612
+ */
613
+ getStoredWorkflowRole() {
614
+ return this.parameters.storedWorkflowRole;
615
+ }
616
+ /**
617
+ * Validate and stamp stored workflows on save, and register them once saved
618
+ */
619
+ listenStored() {
620
+ const repo = useRepository(StoredWorkflow);
621
+ repo.on("Create", ({ object }) => this.prepareStored(object, object));
622
+ repo.on("Update", ({ object }) => this.prepareStored(object, object));
623
+ repo.on("Patch", async ({ object_id, object }) => {
624
+ const current = await runAsSystem(() => StoredWorkflow.ref(object_id).get());
625
+ await this.prepareStored({ ...current, ...object, uuid: object_id }, object);
626
+ });
627
+ // Partial updates (increments, collections, attribute removal) would skip the validation
628
+ repo.on("PartialUpdate", () => {
629
+ throw new WebdaError.BadRequest("A stored workflow is changed with update or patch, not partial updates");
630
+ });
631
+ for (const event of ["Created", "Updated", "Patched", "Deleted", "PartialUpdated"]) {
632
+ repo.on(event, ({ object_id }) => this.storedChanged(object_id));
633
+ }
634
+ }
635
+ /**
636
+ * Validate a stored workflow about to be saved and stamp its version and last saver
637
+ * @param workflow - the workflow as it will be saved
638
+ * @param target - the data written: receives `version` and `updatedBy`
639
+ */
640
+ prepareStored(workflow, target) {
641
+ this.validateStored(workflow);
642
+ target.version = flowVersion(workflow);
643
+ const context = useContext();
644
+ // A save by the system keeps the last saver: its crons never run as the system
645
+ if (context && !context.isGlobalContext() && context.getCurrentUserId()) {
646
+ target.updatedBy = context.getCurrentUserId();
647
+ }
648
+ }
649
+ /**
650
+ * Validate a stored workflow
651
+ * @param workflow - the workflow
652
+ * @throws BadRequest when its name, graph or options are refused
653
+ */
654
+ validateStored(workflow) {
655
+ const fail = (message) => {
656
+ throw new WebdaError.BadRequest(`Invalid workflow: ${message}`);
657
+ };
658
+ const name = workflow?.name;
659
+ if (typeof name !== "string" || !OPERATION_ID.test(name)) {
660
+ fail(`name ${name} must be an operation id (Group.Name)`);
661
+ }
662
+ // The registered operation decides: a module enabled later wins over a stored flow
663
+ if (listFullOperations()[name]) {
664
+ const flow = this.operationFlow(name);
665
+ if (!flow)
666
+ fail(`${name} is already an operation`);
667
+ if (flow.source !== "stored")
668
+ fail(`${name} is already a code flow`);
669
+ }
670
+ if ([...this.storedFlows].some(([uuid, f]) => f.flowId === name && uuid !== workflow.uuid)) {
671
+ fail(`${name} is already a stored workflow`);
672
+ }
673
+ if (!Array.isArray(workflow.params) || workflow.params.some((p) => typeof p !== "string")) {
674
+ fail("params must be an array of names");
675
+ }
676
+ if (workflow.input === null || typeof workflow.input !== "object") {
677
+ fail("input must be a JSON schema");
678
+ }
679
+ if (!Array.isArray(workflow.body)) {
680
+ fail("body must be an array of statements");
681
+ }
682
+ if (workflow.options?.useSystem && !this.parameters.allowStoredSystem) {
683
+ fail("useSystem is not allowed in a stored workflow");
684
+ }
685
+ for (const cron of workflow.crons ?? []) {
686
+ if (typeof cron?.cron !== "string" || (cron.input !== undefined && typeof cron.input !== "object")) {
687
+ fail("a cron needs a cron expression and an object input");
688
+ }
689
+ }
690
+ const walk = (stmts, path) => {
691
+ if (!Array.isArray(stmts))
692
+ fail(`${path} must be an array of statements`);
693
+ stmts.forEach((stmt, i) => {
694
+ const where = typeof stmt?.id === "string" ? stmt.id : `${path}[${i}]`;
695
+ if (typeof stmt?.id !== "string")
696
+ fail(`${where} has no id`);
697
+ const fields = STATEMENT_EXPRESSIONS[stmt.kind];
698
+ if (!fields)
699
+ fail(`${where}: unknown statement kind ${stmt.kind}`);
700
+ for (const field of fields) {
701
+ if (stmt[field] === undefined && field === "value" && stmt.kind === "return")
702
+ continue;
703
+ const invalid = validateExpr(stmt[field], `$.${field}`);
704
+ if (invalid.length)
705
+ fail(`${where}: invalid expression at ${invalid.join(", ")}`);
706
+ }
707
+ if (stmt.kind === "task") {
708
+ if (stmt.target !== undefined) {
709
+ // A typed call compiled from a .flow.ts: it runs the operation of the configured service of its class
710
+ const res = this.resolveTarget(stmt.target);
711
+ if (!res.operation)
712
+ fail(`${where}: ${res.missing ?? res.ambiguous}`);
713
+ }
714
+ else {
715
+ const known = Object.values(BUILTINS).includes(stmt.operation) ||
716
+ stmt.operation === name ||
717
+ !!listFullOperations()[stmt.operation];
718
+ if (!known)
719
+ fail(`${where}: unknown operation ${stmt.operation}`);
720
+ }
721
+ if (stmt.options?.useSystem && !this.parameters.allowStoredSystem) {
722
+ fail(`${where}: useSystem is not allowed in a stored workflow`);
723
+ }
724
+ }
725
+ for (const key of ["then", "else", "body", "catch", "finally"]) {
726
+ if (stmt[key] !== undefined)
727
+ walk(stmt[key], `${where}.${key}`);
728
+ }
729
+ (stmt.branches ?? []).forEach((branch, b) => walk(branch, `${where}.branches[${b}]`));
730
+ });
731
+ };
732
+ walk(workflow.body, "body");
733
+ }
734
+ /**
735
+ * The flow served by an operation, if it is a flow
736
+ * @param flowId - the operation id
737
+ * @returns the flow of the WorkflowService serving the operation
738
+ */
739
+ operationFlow(flowId) {
740
+ const operation = listFullOperations()[flowId];
741
+ if (operation?.context?.flowId !== flowId || !operation.service)
742
+ return undefined;
743
+ const service = operation.service === this.getName() ? this : useDynamicService(operation.service);
744
+ return typeof service?.getFlow === "function" ? service.getFlow(flowId) : undefined;
745
+ }
746
+ /**
747
+ * User a stored flow runs as when the system launches it: its last saver
748
+ * @param flowId - the flow id
749
+ * @returns the user id, undefined when unknown
750
+ */
751
+ storedRunAs(flowId) {
752
+ const local = [...this.storedFlows.values()].find(f => f.flowId === flowId);
753
+ if (local)
754
+ return local.updatedBy;
755
+ const operation = listFullOperations()[flowId];
756
+ const service = operation?.service ? useDynamicService(operation.service) : undefined;
757
+ return service && service !== this && typeof service.storedRunAs === "function"
758
+ ? service.storedRunAs(flowId)
759
+ : undefined;
760
+ }
761
+ /**
762
+ * Identity of a new instance: a stored flow never runs as the system, it runs as its last saver
763
+ * @param def - the flow
764
+ * @param launcher - the identity of the caller (launch) or of the parent instance (child flows)
765
+ * @returns the launcher of the instance
766
+ * @throws Forbidden when a stored flow launched by the system has no user to run as
767
+ */
768
+ instanceLauncher(def, launcher) {
769
+ if (!launcher?.system || def.source !== "stored")
770
+ return launcher;
771
+ const updatedBy = this.storedRunAs(def.id);
772
+ if (!updatedBy) {
773
+ throw new WebdaError.Forbidden(`${def.id} has no user to run as`);
774
+ }
775
+ return { userId: updatedBy };
776
+ }
777
+ /**
778
+ * Identity of a child flow, which must be allowed to call the flow operation like a launch
779
+ * @param def - the child flow
780
+ * @param launcher - the launcher of the parent
781
+ * @param input - the child input
782
+ * @returns the launcher of the child
783
+ * @throws TaskError Forbidden when the child may not be started
784
+ */
785
+ async childLauncher(def, launcher, input) {
786
+ let child;
787
+ try {
788
+ child = this.instanceLauncher(def, launcher);
789
+ }
790
+ catch (err) {
791
+ throw new TaskError("Forbidden", err.message, false);
792
+ }
793
+ const ctx = child.system ? useSystem(input) : this.launcherContext(child, input);
794
+ if (!(await canCallOperation(ctx, def.id, { input }))) {
795
+ throw new TaskError("Forbidden", `${def.id} PermissionDenied`, false);
796
+ }
797
+ return child;
798
+ }
799
+ /**
800
+ * A stored workflow changed on this replica: register it here and tell the others
801
+ * @param uuid - the workflow uuid
802
+ */
803
+ async storedChanged(uuid) {
804
+ await this.syncStored(uuid);
805
+ if (this.parameters.syncPubSub) {
806
+ await useDynamicService(this.parameters.syncPubSub).sendMessage({ workflow: uuid });
807
+ }
808
+ }
809
+ /**
810
+ * Register, re-register or remove a stored workflow from the store
811
+ * @param uuid - the workflow uuid
812
+ */
813
+ async syncStored(uuid) {
814
+ const workflow = await runAsSystem(async () => (await StoredWorkflow.ref(uuid).exists()) ? (await StoredWorkflow.ref(uuid).get()) : undefined);
815
+ if (workflow) {
816
+ this.registerStored(workflow);
817
+ }
818
+ else {
819
+ this.unregisterStored(uuid);
820
+ }
821
+ }
822
+ /**
823
+ * Validate then register a stored workflow; an invalid one is removed and logged
824
+ * @param workflow - the stored workflow
825
+ * @returns true when registered
826
+ */
827
+ registerStored(workflow) {
828
+ try {
829
+ // Every registration path: the store may hold a workflow written behind the validation
830
+ this.validateStored(workflow);
831
+ this.applyStored(workflow);
832
+ return true;
833
+ }
834
+ catch (err) {
835
+ this.log("ERROR", `Stored workflow ${workflow?.name} (${workflow?.uuid}) is not registered`, err);
836
+ this.unregisterStored(workflow.uuid);
837
+ return false;
838
+ }
839
+ }
840
+ /**
841
+ * Register a stored workflow as a flow, with its crons
842
+ * @param workflow - the stored workflow
843
+ */
844
+ applyStored(workflow) {
845
+ const known = this.storedFlows.get(workflow.uuid);
846
+ const crons = JSON.stringify(workflow.crons ?? []);
847
+ const version = workflow.version || flowVersion(workflow);
848
+ if (known && known.flowId === workflow.name && known.version === version && known.crons === crons) {
849
+ known.updatedBy = workflow.updatedBy;
850
+ return;
851
+ }
852
+ if (known && known.flowId !== workflow.name) {
853
+ this.unregisterStored(workflow.uuid);
854
+ }
855
+ // The typed calls of a graph compiled from a .flow.ts run the configured operation, as for the code flow
856
+ const body = structuredClone(workflow.body);
857
+ this.resolveTargets(body);
858
+ this.registerFlow({
859
+ id: workflow.name,
860
+ version,
861
+ params: workflow.params,
862
+ input: workflow.input,
863
+ body,
864
+ options: workflow.options,
865
+ source: "stored"
866
+ });
867
+ for (const id of known?.cronIds ?? [])
868
+ CronService.unregister(id);
869
+ const cronIds = [];
870
+ for (const cron of workflow.crons ?? []) {
871
+ try {
872
+ cronIds.push(CronService.getExportId(CronService.register(workflow.name, cron)));
873
+ }
874
+ catch (err) {
875
+ this.log("ERROR", `Cannot register the cron ${cron.cron} of ${workflow.name}`, err);
876
+ }
877
+ }
878
+ this.storedFlows.set(workflow.uuid, {
879
+ flowId: workflow.name,
880
+ version,
881
+ updatedBy: workflow.updatedBy,
882
+ crons,
883
+ cronIds
884
+ });
885
+ }
886
+ /**
887
+ * Remove a stored workflow: its operation and its crons (running instances keep their pinned definition)
888
+ * @param uuid - the workflow uuid
889
+ */
890
+ unregisterStored(uuid) {
891
+ const known = this.storedFlows.get(uuid);
892
+ if (!known)
893
+ return;
894
+ this.storedFlows.delete(uuid);
895
+ for (const id of known.cronIds)
896
+ CronService.unregister(id);
897
+ // Only what this stored flow registered: a code flow or another operation of the same name stays
898
+ if (this.flows.get(known.flowId)?.source !== "stored")
899
+ return;
900
+ this.flows.delete(known.flowId);
901
+ const operation = useInstanceStorage().operations?.[known.flowId];
902
+ if (operation?.service === this.getName() && operation.context?.flowId === known.flowId) {
903
+ delete useInstanceStorage().operations[known.flowId];
904
+ }
905
+ }
906
+ /**
907
+ * Load every stored workflow: register the new and changed ones, remove the deleted ones
908
+ */
909
+ async reloadStored() {
910
+ const workflows = await runAsSystem(async () => {
911
+ const all = [];
912
+ for await (const workflow of StoredWorkflow.iterate(""))
913
+ all.push(workflow);
914
+ return all;
915
+ });
916
+ const seen = new Set();
917
+ for (const workflow of workflows) {
918
+ seen.add(workflow.uuid);
919
+ this.registerStored(workflow);
920
+ }
921
+ for (const uuid of [...this.storedFlows.keys()]) {
922
+ if (!seen.has(uuid))
923
+ this.unregisterStored(uuid);
924
+ }
925
+ }
926
+ /**
927
+ * Follow the stored workflow changes of the other replicas: through `syncPubSub`, or a reload every
928
+ * `syncInterval`
929
+ */
930
+ startSync() {
931
+ if (this.parameters.syncPubSub) {
932
+ this.syncConsumer ??= useDynamicService(this.parameters.syncPubSub).consume(async (event) => {
933
+ if (event?.workflow)
934
+ await this.syncStored(String(event.workflow));
935
+ });
936
+ return;
937
+ }
938
+ if (this.syncTimer)
939
+ return;
940
+ this.syncTimer = setInterval(() => {
941
+ this.reloadStored().catch(err => this.log("ERROR", "Cannot reload the stored workflows", err));
942
+ }, this.parameters.syncInterval);
943
+ this.syncTimer.unref?.();
944
+ }
945
+ /**
946
+ * Launch the flow of the current operation, as the caller
947
+ * @param args - the flow parameters, in order
948
+ * @returns the instance uuid
949
+ */
950
+ async launch(...args) {
951
+ const context = useContext();
952
+ const flowId = context.getExtension("operationContext")?.flowId;
953
+ const def = flowId ? this.flows.get(flowId) : undefined;
954
+ if (!def) {
955
+ throw new WebdaError.NotFound(`Unknown flow ${flowId}`);
956
+ }
957
+ const input = {};
958
+ def.params.forEach((name, i) => {
959
+ if (args[i] !== undefined)
960
+ input[name] = args[i];
961
+ });
962
+ // A stored workflow never runs as the system: a cron tick runs it as the user who saved it last
963
+ const session = context.getSession();
964
+ const launcher = this.instanceLauncher(def, context.isGlobalContext()
965
+ ? { system: true }
966
+ : {
967
+ userId: context.getCurrentUserId() || undefined,
968
+ identId: session.identUsed,
969
+ scopes: session.scopes,
970
+ clientId: session.clientId
971
+ });
972
+ const instance = await runAsSystem(() => this.createInstance(def, input, launcher));
973
+ return { uuid: instance.uuid };
974
+ }
975
+ /**
976
+ * Create an instance and run it to its first tasks
977
+ * @param def - the flow definition, pinned in the instance
978
+ * @param input - the flow input
979
+ * @param launcher - the identity of the steps
980
+ * @param options - the parent of an awaited child, a fixed uuid (created once)
981
+ * @param options.parent - parent instance and task key
982
+ * @param options.parent.instance - parent instance uuid
983
+ * @param options.parent.key - task key in the parent
984
+ * @param options.uuid - uuid of the instance: an existing one is not created again
985
+ * @returns the instance
986
+ */
987
+ async createInstance(def, input, launcher, options = {}) {
988
+ const now = Date.now();
989
+ if (options.uuid && (await WorkflowInstance.ref(options.uuid).exists())) {
990
+ return (await WorkflowInstance.ref(options.uuid).get());
991
+ }
992
+ const instance = await WorkflowInstance.create({
993
+ ...(options.uuid ? { uuid: options.uuid } : {}),
994
+ ...(options.parent ? { parent: options.parent } : {}),
995
+ flowId: def.id,
996
+ version: def.version,
997
+ definition: def,
998
+ input,
999
+ journal: {},
1000
+ status: "RUNNING",
1001
+ launcher: JSON.parse(JSON.stringify(launcher)),
1002
+ revision: 0,
1003
+ createdAt: now,
1004
+ updatedAt: now
1005
+ });
1006
+ await this.advance(instance.uuid);
1007
+ return instance;
1008
+ }
1009
+ /**
1010
+ * Start the worker loop
1011
+ */
1012
+ startWorker() {
1013
+ if (this.timer)
1014
+ return;
1015
+ this.timer = setInterval(() => this.poll(), this.parameters.pollInterval);
1016
+ this.timer.unref?.();
1017
+ }
1018
+ /**
1019
+ * One iteration of the worker loop: claim due tasks and run them in the background
1020
+ * @returns the poll in progress, it ends when the tasks are claimed and started
1021
+ */
1022
+ poll() {
1023
+ if (this.pollPromise)
1024
+ return this.pollPromise;
1025
+ if (this.stopping)
1026
+ return Promise.resolve();
1027
+ this.pollPromise = this.claimDue()
1028
+ .then(tasks => {
1029
+ for (const task of tasks) {
1030
+ const execution = this.execute(task)
1031
+ .catch(err => this.log("ERROR", "Workflow task failed", task.uuid, err))
1032
+ .finally(() => this.executions.delete(execution));
1033
+ this.executions.add(execution);
1034
+ }
1035
+ })
1036
+ .then(() => this.maybeSweep())
1037
+ .catch(err => this.log("ERROR", "Workflow poll failed", err))
1038
+ .finally(() => {
1039
+ this.pollPromise = undefined;
1040
+ });
1041
+ return this.pollPromise;
1042
+ }
1043
+ /**
1044
+ * Run the recovery sweep when `sweepInterval` passed since the last one
1045
+ */
1046
+ async maybeSweep() {
1047
+ if (this.stopping || Date.now() - this.lastSweep < this.parameters.sweepInterval)
1048
+ return;
1049
+ this.lastSweep = Date.now();
1050
+ await this.sweep();
1051
+ }
1052
+ /**
1053
+ * Replay the stranded instances: active, not updated for `sweepInterval`, without a PENDING or RUNNING task
1054
+ *
1055
+ * A crash between two steps leaves such an instance: replay is idempotent, and the revision CAS of
1056
+ * {@link advance} guards against a concurrent writer.
1057
+ * @returns the number of instances replayed
1058
+ */
1059
+ async sweep() {
1060
+ return runAsSystem(async () => {
1061
+ const before = Date.now() - this.parameters.sweepInterval;
1062
+ let count = 0;
1063
+ for (const status of ["RUNNING", "WAITING"]) {
1064
+ const { results } = await WorkflowInstance.query(`status = ? AND updatedAt < ? LIMIT 100`, [status, before]);
1065
+ for (const instance of results) {
1066
+ const live = (await WorkflowTask.query(`instance = ? AND (status = 'PENDING' OR status = 'RUNNING' OR status = 'WAITING_EVENT' OR status = 'WAITING_CHILD') LIMIT 1`, [instance.uuid])).results;
1067
+ if (live.length)
1068
+ continue;
1069
+ await this.advance(instance.uuid);
1070
+ count++;
1071
+ }
1072
+ }
1073
+ // Buffered signals nobody consumed in time
1074
+ const expired = (await WorkflowSignal.query(`expiresAt <= ? LIMIT 100`, [Date.now()])).results;
1075
+ for (const signal of expired) {
1076
+ await WorkflowSignal.ref(signal.uuid)
1077
+ .delete()
1078
+ .catch(() => { });
1079
+ }
1080
+ return count;
1081
+ });
1082
+ }
1083
+ /**
1084
+ * One poll: claim the due tasks and run them
1085
+ * @returns the number of tasks claimed
1086
+ */
1087
+ async tick() {
1088
+ const tasks = await this.claimDue();
1089
+ await Promise.all(tasks.map(task => this.execute(task)));
1090
+ return tasks.length;
1091
+ }
1092
+ /**
1093
+ * Claim the due tasks, up to the free slots of this replica
1094
+ *
1095
+ * The PENDING tasks due now, and the RUNNING ones whose lease expired (their worker crashed)
1096
+ * @returns the tasks claimed
1097
+ */
1098
+ async claimDue() {
1099
+ const slots = this.parameters.concurrency - this.running.size;
1100
+ if (slots <= 0)
1101
+ return [];
1102
+ return runAsSystem(async () => {
1103
+ const now = Date.now();
1104
+ const candidates = [
1105
+ ...(await WorkflowTask.query(escape(["status = 'PENDING' AND runAt <= ", " LIMIT ", ""], [now, slots]))).results,
1106
+ ...(await WorkflowTask.query(escape(["status = 'RUNNING' AND leaseUntil < ", " LIMIT ", ""], [now, slots]))).results,
1107
+ // Waits whose timeout passed
1108
+ ...(await WorkflowTask.query(escape(["status = 'WAITING_EVENT' AND runAt <= ", " LIMIT ", ""], [now, slots]))).results
1109
+ ];
1110
+ const claimed = [];
1111
+ for (const task of candidates) {
1112
+ if (claimed.length >= slots || this.stopping)
1113
+ break;
1114
+ if (await this.claim(task)) {
1115
+ claimed.push(task);
1116
+ }
1117
+ }
1118
+ return claimed;
1119
+ });
1120
+ }
1121
+ /**
1122
+ * Claim a task with a lease: a conditional patch on its `leaseUntil`
1123
+ *
1124
+ * Reclaiming an expired lease counts as a new attempt.
1125
+ * @param task - the task, as queried
1126
+ * @param extra - other fields to set with the claim
1127
+ * @returns true when this worker holds the task
1128
+ */
1129
+ async claim(task, extra = {}) {
1130
+ const now = Date.now();
1131
+ const patch = {
1132
+ ...extra,
1133
+ status: "RUNNING",
1134
+ owner: this.workerId,
1135
+ leaseUntil: now + this.parameters.leaseDuration,
1136
+ startedAt: now
1137
+ };
1138
+ const reclaim = task.status === "RUNNING";
1139
+ if (reclaim) {
1140
+ patch.attempt = task.attempt + 1;
1141
+ }
1142
+ const previous = task.leaseUntil;
1143
+ try {
1144
+ await WorkflowTask.ref(task.uuid).patch(patch, "leaseUntil", previous);
1145
+ }
1146
+ catch (err) {
1147
+ const current = (await WorkflowTask.ref(task.uuid).get());
1148
+ if (current && current.leaseUntil === previous)
1149
+ throw err;
1150
+ // Another worker has it
1151
+ return false;
1152
+ }
1153
+ Object.assign(task, patch);
1154
+ // A crash recovery gets one more attempt than the retries: past it the task keeps killing its workers
1155
+ if (reclaim && task.attempt - 1 > (task.options?.retries ?? 0) + 1) {
1156
+ this.crashed.add(task);
1157
+ }
1158
+ return true;
1159
+ }
1160
+ /**
1161
+ * Run a claimed task, write its outcome and advance its instance
1162
+ *
1163
+ * Every write of the attempt is conditional on the lease this worker holds: an attempt whose lease was lost (its
1164
+ * worker was too slow and another one reclaimed it) drops its outcome.
1165
+ * @param task - the task
1166
+ * @param advance - advance the instance after the outcome is journaled
1167
+ */
1168
+ async execute(task, advance = true) {
1169
+ const instance = await runAsSystem(() => WorkflowInstance.ref(task.instance).get());
1170
+ if (!isActive(instance) || instance.journal?.[task.key]) {
1171
+ // Cancelled, or its outcome is already journaled (a crash before the row was finished): never run it again
1172
+ await this.finishTask(task, { status: "CANCELLED" });
1173
+ return;
1174
+ }
1175
+ const controller = new AbortController();
1176
+ this.running.set(task.uuid, { task, controller });
1177
+ const renew = setInterval(() => this.renewLease(task), Math.max(this.parameters.leaseDuration / 2, 1));
1178
+ renew.unref?.();
1179
+ let entry;
1180
+ let retryable = false;
1181
+ try {
1182
+ if (this.crashed.has(task)) {
1183
+ throw new TaskError("Crashed", `${task.operation} was abandoned by its worker ${task.attempt - 1} times`, false);
1184
+ }
1185
+ if (task.signaled) {
1186
+ // Resolved by a signal or a child: the outcome is on the row
1187
+ entry = task.error ? { error: task.error } : { result: task.output };
1188
+ }
1189
+ else {
1190
+ entry = { result: await this.runTask(task, instance, controller) };
1191
+ }
1192
+ }
1193
+ catch (err) {
1194
+ const error = this.toFlowError(err, task.operation);
1195
+ retryable =
1196
+ !(err instanceof TaskError && !err.retryable) && !Object.values(NOT_RETRYABLE_STATUS).includes(error.code);
1197
+ entry = { error };
1198
+ }
1199
+ finally {
1200
+ clearInterval(renew);
1201
+ this.running.delete(task.uuid);
1202
+ }
1203
+ if (controller.signal.reason === DROPPED || !(await this.holdsLease(task))) {
1204
+ this.log("DEBUG", "Workflow task attempt dropped", task.uuid);
1205
+ return;
1206
+ }
1207
+ if ("error" in entry) {
1208
+ const retries = task.options?.retries ?? 0;
1209
+ if (retryable && task.attempt <= retries) {
1210
+ await this.scheduleRetry(task);
1211
+ await this.finishTask(task, { status: "ERROR", error: entry.error });
1212
+ return;
1213
+ }
1214
+ }
1215
+ await this.writeJournal(task.instance, task.key, entry);
1216
+ await this.finishTask(task, "error" in entry ? { status: "ERROR", error: entry.error } : { status: "SUCCESS", output: entry.result });
1217
+ if (advance) {
1218
+ await this.advance(task.instance);
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Conditional patch of a task row on the lease this worker holds
1223
+ * @param task - the task, with the lease this worker holds
1224
+ * @param data - the changes
1225
+ * @returns false when another worker holds the lease now
1226
+ */
1227
+ async patchLeased(task, data) {
1228
+ return runAsSystem(async () => {
1229
+ try {
1230
+ await WorkflowTask.ref(task.uuid).patch(data, "leaseUntil", task.leaseUntil);
1231
+ }
1232
+ catch (err) {
1233
+ const current = (await WorkflowTask.ref(task.uuid).get());
1234
+ if (current && current.leaseUntil === task.leaseUntil)
1235
+ throw err;
1236
+ return false;
1237
+ }
1238
+ Object.assign(task, data);
1239
+ return true;
1240
+ });
1241
+ }
1242
+ /**
1243
+ * Check this worker still holds the lease of a task, and extend it
1244
+ * @param task - the task
1245
+ * @returns false when the lease was lost
1246
+ */
1247
+ holdsLease(task) {
1248
+ return this.patchLeased(task, { leaseUntil: Date.now() + this.parameters.leaseDuration });
1249
+ }
1250
+ /**
1251
+ * Extend the lease of a running task, abort the attempt when the lease was lost
1252
+ * @param task - the task
1253
+ */
1254
+ async renewLease(task) {
1255
+ let held = false;
1256
+ try {
1257
+ held = await this.holdsLease(task);
1258
+ }
1259
+ catch (err) {
1260
+ this.log("WARN", "Cannot renew the lease of workflow task", task.uuid, err);
1261
+ }
1262
+ if (!held) {
1263
+ this.running.get(task.uuid)?.controller.abort(DROPPED);
1264
+ }
1265
+ }
1266
+ /**
1267
+ * Record the end of an attempt, if this worker still holds its lease
1268
+ *
1269
+ * `leaseUntil` becomes the end time, never 0: a worker that queried the task before cannot claim it again.
1270
+ * @param task - the task
1271
+ * @param data - status and outcome
1272
+ * @returns false when the lease was lost
1273
+ */
1274
+ finishTask(task, data) {
1275
+ const endedAt = Date.now();
1276
+ return this.patchLeased(task, { ...data, endedAt, leaseUntil: endedAt });
1277
+ }
1278
+ /**
1279
+ * Create the next attempt of a failed task, after the backoff delay
1280
+ * @param task - the failed attempt
1281
+ */
1282
+ async scheduleRetry(task) {
1283
+ const delay = new Duration(task.options?.delay ?? "1s").toMs();
1284
+ const factor = (task.options?.backoff ?? "exponential") === "exponential" ? 2 ** (task.attempt - 1) : 1;
1285
+ await this.createTask(task.instance, { key: task.key, operation: task.operation, input: task.input, options: task.options }, task.launcherId, Date.now() + delay * factor, task.attempt + 1);
1286
+ }
1287
+ /**
1288
+ * Run the operation (or built-in) of a task
1289
+ * @param task - the task
1290
+ * @param instance - its instance
1291
+ * @param controller - aborted on timeout
1292
+ * @returns the result
1293
+ */
1294
+ async runTask(task, instance, controller) {
1295
+ switch (task.operation) {
1296
+ case BUILTINS.DateNow:
1297
+ return Date.now();
1298
+ case BUILTINS.RandomNumber:
1299
+ return Math.random();
1300
+ case BUILTINS.RandomUuid:
1301
+ return randomUUID();
1302
+ case BUILTINS.Sleep:
1303
+ // The timer is the runAt of the task
1304
+ return null;
1305
+ case BUILTINS.WaitForEvent:
1306
+ throw new TaskError("Timeout", `No ${task.event} event for ${task.eventKey} before the timeout`, false);
1307
+ case BUILTINS.Start: {
1308
+ const def = this.resolveFlow(task.input?.flow);
1309
+ if (!def) {
1310
+ throw new TaskError("NotFound", `Unknown flow ${task.input?.flow}`, false);
1311
+ }
1312
+ const input = task.input?.input ?? {};
1313
+ const launcher = await this.childLauncher(def, instance.launcher, input);
1314
+ // The same uuid for every attempt: a child started twice is created once
1315
+ const uuid = taskUuid(task.uuid, "start", 1);
1316
+ await runAsSystem(() => this.createInstance(def, input, launcher, { uuid }));
1317
+ return { uuid };
1318
+ }
1319
+ }
1320
+ if (this.resolveFlow(task.operation)) {
1321
+ throw new TaskError("InvalidTask", `Child flow ${task.operation} runs as a WAITING_CHILD task`, false);
1322
+ }
1323
+ const options = task.options;
1324
+ if (options.useSystem && instance.definition?.source === "stored" && !this.parameters.allowStoredSystem) {
1325
+ throw new TaskError("SystemNotAllowed", "A stored workflow cannot run a task as the system", false);
1326
+ }
1327
+ const ctx = options.useSystem || instance.launcher?.system
1328
+ ? useSystem(task.input ?? null)
1329
+ : this.launcherContext(instance.launcher, task.input);
1330
+ ctx.setExtension("abortSignal", controller.signal);
1331
+ await ctx.init();
1332
+ const call = callOperation(ctx, task.operation);
1333
+ if (options.timeout) {
1334
+ const timeoutMs = new Duration(options.timeout).toMs();
1335
+ let timer;
1336
+ const timeout = new Promise((_resolve, reject) => {
1337
+ timer = setTimeout(() => {
1338
+ controller.abort();
1339
+ reject(new TaskError("Timeout", `${task.operation} timed out after ${options.timeout}`));
1340
+ }, timeoutMs);
1341
+ });
1342
+ // An attempt still running after its timeout is ignored
1343
+ call.catch(() => { });
1344
+ try {
1345
+ await Promise.race([call, timeout]);
1346
+ }
1347
+ finally {
1348
+ clearTimeout(timer);
1349
+ }
1350
+ }
1351
+ else {
1352
+ await call;
1353
+ }
1354
+ const output = ctx.getOutput();
1355
+ if (!output)
1356
+ return null;
1357
+ try {
1358
+ return JSON.parse(output);
1359
+ }
1360
+ catch {
1361
+ // A string result is written as is
1362
+ return output;
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Build the context of the launcher: the session is rebuilt from the user id, so the permissions are fresh
1367
+ * @param launcher - the launcher
1368
+ * @param input - the operation input
1369
+ * @returns the context
1370
+ */
1371
+ launcherContext(launcher, input) {
1372
+ const ctx = new SimpleOperationContext();
1373
+ const session = new Session();
1374
+ if (launcher?.userId) {
1375
+ session.login(launcher.userId, launcher.identId ?? "");
1376
+ }
1377
+ session.scopes = launcher.scopes;
1378
+ session.clientId = launcher.clientId;
1379
+ ctx.setSession(session);
1380
+ ctx.setInput(Buffer.from(JSON.stringify(input ?? null)));
1381
+ return ctx;
1382
+ }
1383
+ /**
1384
+ * Convert an error to the data the flow sees
1385
+ * @param err - the error
1386
+ * @param operation - the operation of the task
1387
+ * @returns the flow error
1388
+ */
1389
+ toFlowError(err, operation) {
1390
+ let code;
1391
+ const status = typeof err?.getResponseCode === "function" ? err.getResponseCode() : undefined;
1392
+ if (err instanceof TaskError) {
1393
+ code = err.code;
1394
+ }
1395
+ else if (NOT_RETRYABLE_STATUS[status]) {
1396
+ code = NOT_RETRYABLE_STATUS[status];
1397
+ }
1398
+ else if (err instanceof WebdaError.HttpError) {
1399
+ code = err.constructor.name;
1400
+ }
1401
+ else {
1402
+ code = typeof err?.code === "string" && err.code ? err.code : err?.name || "Error";
1403
+ }
1404
+ return { code, message: String(err?.message ?? err), operation };
1405
+ }
1406
+ /**
1407
+ * Write a task outcome to the instance journal: a conditional patch on `revision`, retried on conflict
1408
+ *
1409
+ * The first outcome of a key wins. A journal over `maxContextSize` fails the instance.
1410
+ * @param instanceId - the instance uuid
1411
+ * @param key - the task key
1412
+ * @param entry - the outcome
1413
+ * @returns true when the outcome is in the journal
1414
+ */
1415
+ async writeJournal(instanceId, key, entry) {
1416
+ return runAsSystem(async () => {
1417
+ for (;;) {
1418
+ const instance = (await WorkflowInstance.ref(instanceId).get());
1419
+ if (!isActive(instance))
1420
+ return false;
1421
+ if (instance.journal?.[key])
1422
+ return true;
1423
+ const journal = { ...instance.journal, [key]: { ...entry, at: Date.now(), seq: instance.revision + 1 } };
1424
+ const size = Buffer.byteLength(JSON.stringify(journal)) + Buffer.byteLength(JSON.stringify(instance.input));
1425
+ if (size > this.parameters.maxContextSize) {
1426
+ const error = {
1427
+ code: "ContextTooLarge",
1428
+ message: `The context of the workflow is ${size} bytes, over ${this.parameters.maxContextSize}`,
1429
+ operation: instance.flowId
1430
+ };
1431
+ if (await this.patchInstance(instance, { status: "ERROR", error })) {
1432
+ await this.deliverToParent(instance.uuid);
1433
+ return false;
1434
+ }
1435
+ continue;
1436
+ }
1437
+ if (await this.patchInstance(instance, { journal }))
1438
+ return true;
1439
+ }
1440
+ });
1441
+ }
1442
+ /**
1443
+ * Patch an instance if its revision did not change
1444
+ * @param instance - the instance as read
1445
+ * @param data - the changes
1446
+ * @returns false when another writer changed it first
1447
+ */
1448
+ async patchInstance(instance, data) {
1449
+ try {
1450
+ await WorkflowInstance.ref(instance.uuid).patch({ ...data, revision: instance.revision + 1, updatedAt: Date.now() }, "revision", instance.revision);
1451
+ return true;
1452
+ }
1453
+ catch (err) {
1454
+ const current = (await WorkflowInstance.ref(instance.uuid).get());
1455
+ if (current?.revision === instance.revision)
1456
+ throw err;
1457
+ return false;
1458
+ }
1459
+ }
1460
+ /**
1461
+ * Replay an instance and act on the result: finish it, or create its missing tasks
1462
+ * @param instanceId - the instance uuid
1463
+ */
1464
+ async advance(instanceId) {
1465
+ await runAsSystem(async () => {
1466
+ for (;;) {
1467
+ const instance = (await WorkflowInstance.ref(instanceId).get());
1468
+ if (!isActive(instance))
1469
+ return;
1470
+ const res = run(instance.definition, instance.input, instance.journal);
1471
+ if (res.status === "done" || res.status === "error") {
1472
+ const end = res.status === "done" ? { status: "SUCCESS", output: res.output } : { status: "ERROR", error: res.error };
1473
+ if (await this.patchInstance(instance, end)) {
1474
+ await this.deliverToParent(instanceId);
1475
+ return;
1476
+ }
1477
+ continue;
1478
+ }
1479
+ let resolved = false;
1480
+ for (const scheduled of res.schedule) {
1481
+ resolved = (await this.scheduleTask(instance, scheduled)) || resolved;
1482
+ }
1483
+ // A buffered signal resolved a new wait: replay again
1484
+ if (resolved)
1485
+ continue;
1486
+ const status = res.schedule.every(t => WAITING_OPERATIONS.includes(t.operation) || this.resolveFlow(t.operation))
1487
+ ? "WAITING"
1488
+ : "RUNNING";
1489
+ if (status === instance.status || (await this.patchInstance(instance, { status })))
1490
+ return;
1491
+ }
1492
+ });
1493
+ }
1494
+ /**
1495
+ * Create the task of a scheduled key, unless a PENDING or RUNNING one exists
1496
+ * @param instance - the instance
1497
+ * @param scheduled - the task to run
1498
+ * @returns true when the task was resolved at once (a buffered signal): the journal changed
1499
+ */
1500
+ async scheduleTask(instance, scheduled) {
1501
+ const existing = (await WorkflowTask.query(`instance = ? AND key = ?`, [instance.uuid, scheduled.key]))
1502
+ .results;
1503
+ // A SUCCESS row is journaled before it is finished: the replay read the instance before it
1504
+ if (existing.some(t => LIVE_TASK.includes(t.status) || t.status === "SUCCESS"))
1505
+ return false;
1506
+ const now = Date.now();
1507
+ let runAt = now;
1508
+ let extra = {};
1509
+ if (scheduled.operation === BUILTINS.Sleep) {
1510
+ runAt = this.sleepUntil(scheduled.input, runAt);
1511
+ }
1512
+ else if (this.resolveFlow(scheduled.operation)) {
1513
+ return this.scheduleChild(instance, scheduled, existing.length + 1);
1514
+ }
1515
+ else if (scheduled.operation === BUILTINS.WaitForEvent) {
1516
+ const input = scheduled.input ?? {};
1517
+ extra = {
1518
+ status: "WAITING_EVENT",
1519
+ event: String(input.name),
1520
+ eventKey: String(input.key),
1521
+ allow: Array.isArray(input.allow) ? input.allow.map(String) : undefined
1522
+ };
1523
+ runAt = input.timeout ? this.sleepUntil({ duration: input.timeout }, now) : NO_TIMEOUT;
1524
+ }
1525
+ const task = await this.createTask(instance.uuid, scheduled, instance.launcher?.userId, runAt, 1, existing.length + 1, extra);
1526
+ return task?.status === "WAITING_EVENT" ? this.consumeBuffered(instance, task) : false;
1527
+ }
1528
+ /**
1529
+ * Start an awaited child flow: the task waits for the child instance, which runs as the same launcher
1530
+ * @param instance - the parent instance
1531
+ * @param scheduled - the task, its operation is a flow id
1532
+ * @param index - row index of the key
1533
+ * @returns true when the child finished at once: the parent journal changed
1534
+ */
1535
+ async scheduleChild(instance, scheduled, index) {
1536
+ const def = this.resolveFlow(scheduled.operation);
1537
+ // Created with the row: a worker resuming after a crash finds the same child
1538
+ const child = taskUuid(instance.uuid, `${scheduled.key}#child`, index);
1539
+ const task = await this.createTask(instance.uuid, scheduled, instance.launcher?.userId, NO_TIMEOUT, 1, index, {
1540
+ status: "WAITING_CHILD",
1541
+ child
1542
+ });
1543
+ if (!task)
1544
+ return false;
1545
+ let launcher;
1546
+ try {
1547
+ launcher = await this.childLauncher(def, instance.launcher, scheduled.input ?? {});
1548
+ }
1549
+ catch (err) {
1550
+ // Refused: the task fails like any task, the flow can catch it
1551
+ if (await this.claim(task, { signaled: true, error: this.toFlowError(err, scheduled.operation) })) {
1552
+ await this.execute(task, false);
1553
+ return true;
1554
+ }
1555
+ return false;
1556
+ }
1557
+ await this.createInstance(def, scheduled.input ?? {}, launcher, {
1558
+ parent: { instance: instance.uuid, key: scheduled.key },
1559
+ uuid: child
1560
+ });
1561
+ return (await WorkflowTask.ref(task.uuid).get()).status !== "WAITING_CHILD";
1562
+ }
1563
+ /**
1564
+ * Journal the end of a child into its parent: its output, its error, or `Cancelled`
1565
+ * @param childId - the child instance uuid
1566
+ */
1567
+ async deliverToParent(childId) {
1568
+ await runAsSystem(async () => {
1569
+ const child = (await WorkflowInstance.ref(childId).get());
1570
+ if (!child?.parent || isActive(child))
1571
+ return;
1572
+ const rows = (await WorkflowTask.query(`instance = ? AND key = ? AND status = 'WAITING_CHILD'`, [
1573
+ child.parent.instance,
1574
+ child.parent.key
1575
+ ])).results;
1576
+ const row = rows.find(r => r.child === child.uuid);
1577
+ if (!row)
1578
+ return;
1579
+ const outcome = child.status === "SUCCESS"
1580
+ ? { signaled: true, output: child.output }
1581
+ : {
1582
+ signaled: true,
1583
+ error: child.error ?? {
1584
+ code: "Cancelled",
1585
+ message: "The child flow was cancelled",
1586
+ operation: child.flowId
1587
+ }
1588
+ };
1589
+ if (await this.claim(row, outcome)) {
1590
+ await this.execute(row);
1591
+ }
1592
+ });
1593
+ }
1594
+ /**
1595
+ * Cancel an instance, its live tasks and the children it waits on
1596
+ * @param uuid - the instance uuid
1597
+ */
1598
+ async cancelInstance(uuid) {
1599
+ await runAsSystem(async () => {
1600
+ if (!(await this.cancelTree(uuid))) {
1601
+ throw new WebdaError.BadRequest("cancel: the instance is not running");
1602
+ }
1603
+ });
1604
+ }
1605
+ /**
1606
+ * Cancel an instance and, recursively, the children it waits on
1607
+ * @param uuid - the instance uuid
1608
+ * @returns false when the instance was not running
1609
+ */
1610
+ async cancelTree(uuid) {
1611
+ for (;;) {
1612
+ const instance = (await WorkflowInstance.ref(uuid).get());
1613
+ if (!isActive(instance))
1614
+ return false;
1615
+ if (await this.patchInstance(instance, { status: "CANCELLED" }))
1616
+ break;
1617
+ }
1618
+ for (const row of (await WorkflowTask.query(`instance = ?`, [uuid])).results) {
1619
+ if (!LIVE_TASK.includes(row.status))
1620
+ continue;
1621
+ // A local attempt stops now, a remote one loses its lease and drops its outcome
1622
+ this.running.get(row.uuid)?.controller.abort(DROPPED);
1623
+ await this.supersede(row);
1624
+ if (row.status === "WAITING_CHILD" && row.child) {
1625
+ await this.cancelTree(row.child);
1626
+ }
1627
+ }
1628
+ // A child cancelled on its own throws Cancelled in its parent
1629
+ await this.deliverToParent(uuid);
1630
+ return true;
1631
+ }
1632
+ /**
1633
+ * Mark a task row CANCELLED, conditionally on its lease: whoever holds it can no longer write its outcome
1634
+ * @param row - the task row, as read
1635
+ */
1636
+ async supersede(row) {
1637
+ const status = row.status;
1638
+ for (let current = row; current.status !== "CANCELLED";) {
1639
+ const now = Date.now();
1640
+ if (await this.patchLeased(current, { status: "CANCELLED", endedAt: now, leaseUntil: now }))
1641
+ break;
1642
+ current = (await WorkflowTask.ref(row.uuid).get());
1643
+ }
1644
+ // Keep the status seen by the caller
1645
+ row.status = status;
1646
+ }
1647
+ /**
1648
+ * Restart a failed instance: drop the outcome that failed it and every outcome journaled after it, then replay
1649
+ * @param uuid - the instance uuid
1650
+ */
1651
+ async retryInstance(uuid) {
1652
+ await runAsSystem(async () => {
1653
+ let kept;
1654
+ for (;;) {
1655
+ const instance = (await WorkflowInstance.ref(uuid).get());
1656
+ if (instance?.status !== "ERROR") {
1657
+ throw new WebdaError.BadRequest("retry: only a failed instance can be retried");
1658
+ }
1659
+ const order = (entry) => entry.seq ?? entry.at ?? 0;
1660
+ const entries = Object.entries(instance.journal);
1661
+ const failing = entries
1662
+ .filter(([, entry]) => "error" in entry && sameError(entry.error, instance.error))
1663
+ .sort(([, a], [, b]) => order(b) - order(a))[0];
1664
+ if (!failing) {
1665
+ throw new WebdaError.BadRequest("retry: the error does not come from a task");
1666
+ }
1667
+ kept = Object.fromEntries(entries.filter(([key, entry]) => key !== failing[0] && order(entry) < order(failing[1])));
1668
+ if (await this.patchInstance(instance, { status: "RUNNING", journal: kept, error: null }))
1669
+ break;
1670
+ }
1671
+ // Finished rows of the dropped keys would stop them from being scheduled again
1672
+ for (const row of (await WorkflowTask.query(`instance = ?`, [uuid])).results) {
1673
+ if ((row.status === "SUCCESS" || row.status === "ERROR") && !kept[row.key]) {
1674
+ await this.supersede(row);
1675
+ }
1676
+ }
1677
+ await this.advance(uuid);
1678
+ });
1679
+ }
1680
+ /**
1681
+ * Move a running instance to another version of its flow
1682
+ *
1683
+ * Refused when a journaled or live task key has no statement in the target graph.
1684
+ * @param uuid - the instance uuid
1685
+ * @param version - the target version, the latest registered when undefined
1686
+ */
1687
+ async migrateInstance(uuid, version) {
1688
+ await runAsSystem(async () => {
1689
+ for (;;) {
1690
+ const instance = (await WorkflowInstance.ref(uuid).get());
1691
+ if (!isActive(instance)) {
1692
+ throw new WebdaError.BadRequest("migrate: the instance is not running");
1693
+ }
1694
+ const target = version ? this.getFlowVersion(instance.flowId, version) : this.resolveFlow(instance.flowId);
1695
+ if (!target) {
1696
+ throw new WebdaError.BadRequest(`migrate: unknown version ${version ?? "(latest)"} of ${instance.flowId}`);
1697
+ }
1698
+ const ids = keysOf(target);
1699
+ const live = (await WorkflowTask.query(`instance = ?`, [uuid])).results
1700
+ .filter(row => LIVE_TASK.includes(row.status))
1701
+ .map(row => row.key);
1702
+ const missing = [...new Set([...Object.keys(instance.journal), ...live])].filter(key => !ids.has(statementIdOf(key)));
1703
+ if (missing.length) {
1704
+ throw new WebdaError.BadRequest(`migrate: ${missing.join(", ")} not in version ${target.version}`);
1705
+ }
1706
+ if (await this.patchInstance(instance, { definition: target, version: target.version }))
1707
+ break;
1708
+ }
1709
+ await this.advance(uuid);
1710
+ });
1711
+ }
1712
+ /**
1713
+ * Deliver a signal: resolve the waits on `(event, key)` whose instance accepts the caller, or buffer it when
1714
+ * nothing waits
1715
+ * @param event - event name
1716
+ * @param key - business key
1717
+ * @param payload - the result of the waits
1718
+ * @returns the number of waits resolved
1719
+ */
1720
+ async signal(event, key, payload) {
1721
+ const context = useContext();
1722
+ if (!context.isGlobalContext() && !context.getCurrentUserId()) {
1723
+ throw new WebdaError.Unauthorized("Workflow.Signal needs an authenticated caller");
1724
+ }
1725
+ if (Buffer.byteLength(JSON.stringify(payload ?? null)) > this.parameters.maxSignalPayload) {
1726
+ throw new WebdaError.BadRequest(`Workflow.Signal payload is over ${this.parameters.maxSignalPayload} bytes`);
1727
+ }
1728
+ const waits = await runAsSystem(() => this.waitsFor(event, key));
1729
+ if (waits.length === 0) {
1730
+ const buffered = await this.bufferSignal(context, event, key, payload);
1731
+ // A wait created meanwhile looked for buffered signals before this one was saved
1732
+ return { delivered: (await this.deliverBuffered(buffered)) ? 1 : 0 };
1733
+ }
1734
+ let delivered = 0;
1735
+ let refused = 0;
1736
+ for (const { instance, task } of waits) {
1737
+ if (!(await this.canSignal(context, instance, task))) {
1738
+ refused++;
1739
+ continue;
1740
+ }
1741
+ if (await runAsSystem(() => this.resolveWait(task, payload))) {
1742
+ delivered++;
1743
+ }
1744
+ }
1745
+ // Refused by every wait, or waits resolved by a concurrent signal: not delivered, not buffered. A refused sender
1746
+ // gets the answer of "nothing waits", so the pending waits cannot be probed
1747
+ return { delivered };
1748
+ }
1749
+ /**
1750
+ * The waits on `(event, key)` of active instances
1751
+ * @param event - event name
1752
+ * @param key - business key
1753
+ * @returns the waits and their instance
1754
+ */
1755
+ async waitsFor(event, key) {
1756
+ const tasks = (await WorkflowTask.query(`status = 'WAITING_EVENT' AND event = ? AND eventKey = ?`, [event, key]))
1757
+ .results;
1758
+ const waits = [];
1759
+ for (const task of tasks) {
1760
+ const instance = (await WorkflowInstance.ref(task.instance).get());
1761
+ if (isActive(instance))
1762
+ waits.push({ instance, task });
1763
+ }
1764
+ return waits;
1765
+ }
1766
+ /**
1767
+ * Whether a sender may signal a wait: the launcher, the system, and the users in the `allow` of the wait
1768
+ * @param context - the sender
1769
+ * @param instance - the instance waiting
1770
+ * @param task - the wait
1771
+ * @returns true when allowed
1772
+ */
1773
+ async canSignal(context, instance, task) {
1774
+ if ((await instance.canAct(context, "signal")) === true)
1775
+ return true;
1776
+ const userId = context.getCurrentUserId();
1777
+ return !!userId && (task.allow ?? []).includes(userId);
1778
+ }
1779
+ /**
1780
+ * Save a signal nothing waits for yet
1781
+ * @param context - the sender
1782
+ * @param event - event name
1783
+ * @param key - business key
1784
+ * @param payload - the payload
1785
+ * @returns the buffered signal
1786
+ */
1787
+ async bufferSignal(context, event, key, payload) {
1788
+ const now = Date.now();
1789
+ const system = context.isGlobalContext();
1790
+ const pending = await runAsSystem(async () => (await WorkflowSignal.query(escape(["event = ? AND key = ? AND expiresAt > ? LIMIT ", ""], [this.parameters.maxBufferedSignals]), [event, key, now])).results.length);
1791
+ if (pending >= this.parameters.maxBufferedSignals) {
1792
+ // Refused rather than dropping an older one: an accepted signal is never lost
1793
+ throw new WebdaError.TooManyRequests(`Too many buffered signals for ${event}/${key}`);
1794
+ }
1795
+ return runAsSystem(() => WorkflowSignal.create({
1796
+ event,
1797
+ key,
1798
+ payload,
1799
+ senderId: system ? undefined : context.getCurrentUserId(),
1800
+ senderIdent: system ? undefined : context.getSession()?.identUsed,
1801
+ system: system || undefined,
1802
+ createdAt: now,
1803
+ expiresAt: now + new Duration(this.parameters.signalTtl).toMs()
1804
+ }));
1805
+ }
1806
+ /**
1807
+ * Deliver a just buffered signal to a wait created while it was being saved
1808
+ * @param signal - the buffered signal
1809
+ * @returns true when a wait consumed it
1810
+ */
1811
+ async deliverBuffered(signal) {
1812
+ return runAsSystem(async () => {
1813
+ for (const { instance, task } of await this.waitsFor(signal.event, signal.key)) {
1814
+ if (await this.consumeSignal(signal, instance, task, true))
1815
+ return true;
1816
+ }
1817
+ return false;
1818
+ });
1819
+ }
1820
+ /**
1821
+ * Resolve a new wait with the oldest unexpired buffered signal its instance accepts
1822
+ * @param instance - the instance
1823
+ * @param task - the wait
1824
+ * @returns true when a signal was consumed
1825
+ */
1826
+ async consumeBuffered(instance, task) {
1827
+ const signals = (await WorkflowSignal.query(`event = ? AND key = ? AND expiresAt > ?`, [task.event, task.eventKey, Date.now()]))
1828
+ .results.sort((a, b) => a.createdAt - b.createdAt);
1829
+ for (const signal of signals) {
1830
+ // Advanced by the caller (advance)
1831
+ if (await this.consumeSignal(signal, instance, task, false))
1832
+ return true;
1833
+ }
1834
+ return false;
1835
+ }
1836
+ /**
1837
+ * Consume a buffered signal for a wait: its sender is checked now, against this instance
1838
+ * @param signal - the buffered signal
1839
+ * @param instance - the instance waiting
1840
+ * @param task - the wait
1841
+ * @param advance - advance the instance once the payload is journaled
1842
+ * @returns true when consumed and delivered
1843
+ */
1844
+ async consumeSignal(signal, instance, task, advance) {
1845
+ if (signal.expiresAt <= Date.now())
1846
+ return false;
1847
+ const sender = signal.system
1848
+ ? useSystem()
1849
+ : this.launcherContext({ userId: signal.senderId, identId: signal.senderIdent }, null);
1850
+ if (!(await this.canSignal(sender, instance, task)))
1851
+ return false;
1852
+ try {
1853
+ // Consumed once: a concurrent consumer fails the condition
1854
+ await WorkflowSignal.ref(signal.uuid).delete("expiresAt", signal.expiresAt);
1855
+ }
1856
+ catch {
1857
+ return false;
1858
+ }
1859
+ if (await this.resolveWait(task, signal.payload, advance))
1860
+ return true;
1861
+ // The wait timed out or another signal resolved it first: the signal stays buffered
1862
+ const { uuid, event, key, payload, senderId, senderIdent, system, createdAt, expiresAt } = signal;
1863
+ await WorkflowSignal.create({
1864
+ uuid,
1865
+ event,
1866
+ key,
1867
+ payload,
1868
+ senderId,
1869
+ senderIdent,
1870
+ system,
1871
+ createdAt,
1872
+ expiresAt
1873
+ });
1874
+ return false;
1875
+ }
1876
+ /**
1877
+ * Resolve a wait with a payload: claimed like a task, so it resolves once
1878
+ * @param task - the wait
1879
+ * @param payload - its result
1880
+ * @param advance - advance the instance once the payload is journaled
1881
+ * @returns false when the wait was already resolved or timed out
1882
+ */
1883
+ async resolveWait(task, payload, advance = true) {
1884
+ if (!(await this.claim(task, { signaled: true, output: payload })))
1885
+ return false;
1886
+ await this.execute(task, advance);
1887
+ return true;
1888
+ }
1889
+ /**
1890
+ * End of a `Workflow.Sleep`
1891
+ * @param input - `{ duration?: string, until?: number }`
1892
+ * @param now - the current time
1893
+ * @returns when the timer fires
1894
+ */
1895
+ sleepUntil(input, now) {
1896
+ if (typeof input?.until === "number") {
1897
+ return input.until;
1898
+ }
1899
+ try {
1900
+ return now + new Duration(input?.duration ?? 0).toMs();
1901
+ }
1902
+ catch {
1903
+ // An invalid duration fires at once
1904
+ return now;
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Create a task row, unless another worker created the same row first
1909
+ * @param instance - the instance uuid
1910
+ * @param scheduled - the task
1911
+ * @param launcherId - user id of the launcher
1912
+ * @param runAt - when it becomes runnable
1913
+ * @param attempt - attempt number
1914
+ * @param index - row index of the key, when known
1915
+ * @param extra - other fields of the row
1916
+ * @returns the task created, undefined when another worker created it
1917
+ */
1918
+ async createTask(instance, scheduled, launcherId, runAt, attempt, index, extra = {}) {
1919
+ return runAsSystem(async () => {
1920
+ index ??= (await WorkflowTask.query(`instance = ? AND key = ?`, [instance, scheduled.key])).results.length + 1;
1921
+ const uuid = taskUuid(instance, scheduled.key, index);
1922
+ try {
1923
+ return (await WorkflowTask.create({
1924
+ uuid,
1925
+ instance,
1926
+ key: scheduled.key,
1927
+ operation: scheduled.operation,
1928
+ input: scheduled.input,
1929
+ options: scheduled.options,
1930
+ attempt,
1931
+ status: "PENDING",
1932
+ runAt,
1933
+ leaseUntil: 0,
1934
+ launcherId,
1935
+ ...extra
1936
+ }));
1937
+ }
1938
+ catch (err) {
1939
+ if (!(await WorkflowTask.ref(uuid).exists()))
1940
+ throw err;
1941
+ // Created by another worker
1942
+ return undefined;
1943
+ }
1944
+ });
1945
+ }
1946
+ };
1947
+ })();
1948
+ export { WorkflowService };
1949
+ export default WorkflowService;
1950
+ //# sourceMappingURL=workflow.service.js.map