@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.
- package/LICENSE +166 -0
- package/README.md +54 -2
- package/lib/authoring.d.ts +112 -0
- package/lib/authoring.d.ts.map +1 -0
- package/lib/authoring.js +107 -0
- package/lib/authoring.js.map +1 -0
- package/lib/expression.d.ts +21 -0
- package/lib/expression.d.ts.map +1 -0
- package/lib/expression.js +196 -0
- package/lib/expression.js.map +1 -0
- package/lib/graph.d.ts +291 -0
- package/lib/graph.d.ts.map +1 -0
- package/lib/graph.js +72 -0
- package/lib/graph.js.map +1 -0
- package/lib/hooks.d.ts +38 -0
- package/lib/hooks.d.ts.map +1 -0
- package/lib/hooks.js +23 -0
- package/lib/hooks.js.map +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -0
- package/lib/interpreter.d.ts +63 -0
- package/lib/interpreter.d.ts.map +1 -0
- package/lib/interpreter.js +330 -0
- package/lib/interpreter.js.map +1 -0
- package/lib/models/workflow.model.d.ts +71 -0
- package/lib/models/workflow.model.d.ts.map +1 -0
- package/lib/models/workflow.model.js +70 -0
- package/lib/models/workflow.model.js.map +1 -0
- package/lib/models/workflowinstance.model.d.ts +132 -0
- package/lib/models/workflowinstance.model.d.ts.map +1 -0
- package/lib/models/workflowinstance.model.js +168 -0
- package/lib/models/workflowinstance.model.js.map +1 -0
- package/lib/models/workflowsignal.model.d.ts +51 -0
- package/lib/models/workflowsignal.model.d.ts.map +1 -0
- package/lib/models/workflowsignal.model.js +53 -0
- package/lib/models/workflowsignal.model.js.map +1 -0
- package/lib/models/workflowtask.model.d.ts +103 -0
- package/lib/models/workflowtask.model.d.ts.map +1 -0
- package/lib/models/workflowtask.model.js +104 -0
- package/lib/models/workflowtask.model.js.map +1 -0
- package/lib/workflow.service.d.ts +670 -0
- package/lib/workflow.service.d.ts.map +1 -0
- package/lib/workflow.service.js +1950 -0
- package/lib/workflow.service.js.map +1 -0
- package/package.json +68 -3
- 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
|