@voyantjs/workflows-orchestrator-node 0.30.3 → 0.30.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node-standalone-driver.d.ts +4 -0
- package/dist/node-standalone-driver.d.ts.map +1 -1
- package/dist/node-standalone-driver.js +90 -6
- package/dist/scheduler.d.ts +1 -33
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +1 -111
- package/package.json +3 -3
- package/src/node-standalone-driver.ts +132 -5
- package/src/scheduler.ts +6 -147
|
@@ -49,6 +49,10 @@ export interface NodeStandaloneDriverOptions {
|
|
|
49
49
|
* cadence. Defaults to `false` (poller starts immediately).
|
|
50
50
|
*/
|
|
51
51
|
disableTimeWheel?: boolean;
|
|
52
|
+
/** Schedule runner tick interval. Defaults to 1_000 ms. */
|
|
53
|
+
schedulePollIntervalMs?: number;
|
|
54
|
+
/** Disable automatic firing for schedules registered through manifests. */
|
|
55
|
+
disableScheduleRunner?: boolean;
|
|
52
56
|
}
|
|
53
57
|
/**
|
|
54
58
|
* Build the Mode 2 driver factory. The factory closes over its options
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node-standalone-driver.d.ts","sourceRoot":"","sources":["../src/node-standalone-driver.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EACV,aAAa,EAOd,MAAM,4BAA4B,CAAA;AAInC,OAAO,
|
|
1
|
+
{"version":3,"file":"node-standalone-driver.d.ts","sourceRoot":"","sources":["../src/node-standalone-driver.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EACV,aAAa,EAOd,MAAM,4BAA4B,CAAA;AAInC,OAAO,EAML,KAAK,SAAS,EAId,KAAK,WAAW,EAEjB,MAAM,kCAAkC,CAAA;AACzC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAWxD,KAAK,EAAE,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAIpC,MAAM,WAAW,2BAA2B;IAC1C,4EAA4E;IAC5E,EAAE,EAAE,EAAE,CAAA;IACN,wEAAwE;IACxE,kBAAkB,CAAC,EAAE,YAAY,GAAG,SAAS,GAAG,aAAa,CAAA;IAC7D,wDAAwD;IACxD,UAAU,CAAC,EAAE,SAAS,CAAC,YAAY,CAAC,CAAA;IACpC,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,2DAA2D;IAC3D,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,2EAA2E;IAC3E,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAUD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,2BAA2B,GAAG,aAAa,CAuX3F"}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// See architecture doc §7 for the full Mode 2 design.
|
|
19
19
|
import { deriveStableEventId } from "@voyantjs/workflows/events";
|
|
20
20
|
import { handleStepRequest } from "@voyantjs/workflows/handler";
|
|
21
|
-
import { cancel as orchestratorCancel, trigger as orchestratorTrigger, routeEvent, } from "@voyantjs/workflows-orchestrator";
|
|
21
|
+
import { createInProcessConcurrencyCoordinator, createScheduler, manifestScheduleSources, cancel as orchestratorCancel, trigger as orchestratorTrigger, routeEvent, } from "@voyantjs/workflows-orchestrator";
|
|
22
22
|
import { createPersistentWakeupManager, } from "./persistent-wakeup-manager.js";
|
|
23
23
|
import { createPostgresManifestStore } from "./postgres-manifest-store.js";
|
|
24
24
|
import { createPostgresRunRecordStore } from "./postgres-run-record-store.js";
|
|
@@ -98,7 +98,16 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
98
98
|
// via `manager.poll()`).
|
|
99
99
|
wakeupManager.start();
|
|
100
100
|
}
|
|
101
|
+
const scheduleRunners = new Map();
|
|
101
102
|
let shuttingDown = false;
|
|
103
|
+
const concurrency = createInProcessConcurrencyCoordinator({
|
|
104
|
+
async cancelRun(runId, reason) {
|
|
105
|
+
const out = await orchestratorCancel({ runId, reason }, { store: runStore, handler, now });
|
|
106
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
107
|
+
concurrency.releaseRun(out.record);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
102
111
|
// ---- WorkflowDriver implementation ----
|
|
103
112
|
async function registerManifest(args) {
|
|
104
113
|
assertNotShutdown(shuttingDown);
|
|
@@ -117,6 +126,7 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
117
126
|
error: err instanceof Error ? err.message : String(err),
|
|
118
127
|
});
|
|
119
128
|
}
|
|
129
|
+
startScheduleRunner(args.environment, args.manifest);
|
|
120
130
|
return result;
|
|
121
131
|
}
|
|
122
132
|
async function getManifest(args) {
|
|
@@ -129,7 +139,8 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
129
139
|
assertNotShutdown(shuttingDown);
|
|
130
140
|
const workflowId = typeof workflow === "string" ? workflow : workflow.id;
|
|
131
141
|
const env = triggerOpts?.environment ?? defaultEnv;
|
|
132
|
-
const
|
|
142
|
+
const policy = await resolveConcurrencyPolicy(workflow, workflowId, env, getManifest);
|
|
143
|
+
const record = await triggerRecord({
|
|
133
144
|
workflowId,
|
|
134
145
|
workflowVersion: triggerOpts?.lockToVersion ?? "v1",
|
|
135
146
|
input: input,
|
|
@@ -139,7 +150,7 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
139
150
|
idempotencyKey: triggerOpts?.idempotencyKey,
|
|
140
151
|
delay: triggerOpts?.delay,
|
|
141
152
|
priority: triggerOpts?.priority,
|
|
142
|
-
},
|
|
153
|
+
}, policy);
|
|
143
154
|
// Sync wakeup row so the time-wheel can resume DATETIME-parked runs.
|
|
144
155
|
// No-op if the run completed inline (status !== "waiting").
|
|
145
156
|
await syncWakeupFromRecord(wakeupStore, record);
|
|
@@ -177,7 +188,7 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
177
188
|
continue;
|
|
178
189
|
}
|
|
179
190
|
try {
|
|
180
|
-
const record = await
|
|
191
|
+
const record = await triggerRecord({
|
|
181
192
|
workflowId: entry.targetWorkflowId,
|
|
182
193
|
workflowVersion: "v1",
|
|
183
194
|
input: entry.input,
|
|
@@ -190,7 +201,7 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
190
201
|
eventType: args.envelope.name,
|
|
191
202
|
filterId: entry.filterId,
|
|
192
203
|
},
|
|
193
|
-
},
|
|
204
|
+
}, await resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, getManifest));
|
|
194
205
|
await syncWakeupFromRecord(wakeupStore, record);
|
|
195
206
|
matches.push({
|
|
196
207
|
filterId: entry.filterId,
|
|
@@ -226,6 +237,55 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
226
237
|
// Idempotent — calling stop() on an already-stopped manager is a
|
|
227
238
|
// no-op.
|
|
228
239
|
wakeupManager.stop();
|
|
240
|
+
for (const runner of scheduleRunners.values()) {
|
|
241
|
+
runner.stop();
|
|
242
|
+
}
|
|
243
|
+
scheduleRunners.clear();
|
|
244
|
+
}
|
|
245
|
+
function startScheduleRunner(environment, manifest) {
|
|
246
|
+
const existing = scheduleRunners.get(environment);
|
|
247
|
+
existing?.stop();
|
|
248
|
+
scheduleRunners.delete(environment);
|
|
249
|
+
if (opts.disableScheduleRunner)
|
|
250
|
+
return;
|
|
251
|
+
const sources = manifestScheduleSources(manifest);
|
|
252
|
+
if (sources.length === 0)
|
|
253
|
+
return;
|
|
254
|
+
const runner = createScheduler({
|
|
255
|
+
sources,
|
|
256
|
+
environment,
|
|
257
|
+
now,
|
|
258
|
+
tickMs: opts.schedulePollIntervalMs,
|
|
259
|
+
onFire: async ({ workflowId, input, scheduleId, fireAt }) => {
|
|
260
|
+
assertNotShutdown(shuttingDown);
|
|
261
|
+
const record = await triggerRecord({
|
|
262
|
+
workflowId,
|
|
263
|
+
workflowVersion: "v1",
|
|
264
|
+
input,
|
|
265
|
+
tenantMeta,
|
|
266
|
+
environment,
|
|
267
|
+
idempotencyKey: `${scheduleId}:${fireAt}`,
|
|
268
|
+
triggeredBy: { kind: "schedule", scheduleId },
|
|
269
|
+
}, await resolveConcurrencyPolicy(workflowId, workflowId, environment, getManifest));
|
|
270
|
+
await syncWakeupFromRecord(wakeupStore, record);
|
|
271
|
+
},
|
|
272
|
+
logger: (level, msg, data) => deps.logger(level, msg, data),
|
|
273
|
+
});
|
|
274
|
+
if (runner.sourceCount() === 0)
|
|
275
|
+
return;
|
|
276
|
+
runner.start();
|
|
277
|
+
scheduleRunners.set(environment, runner);
|
|
278
|
+
}
|
|
279
|
+
async function triggerRecord(args, policy) {
|
|
280
|
+
return concurrency.run({
|
|
281
|
+
workflowId: args.workflowId,
|
|
282
|
+
input: args.input,
|
|
283
|
+
policy,
|
|
284
|
+
holderId: concurrencyHolderId(args),
|
|
285
|
+
}, (hooks) => orchestratorTrigger({
|
|
286
|
+
...args,
|
|
287
|
+
onRunRecordCreated: hooks.onRunRecordCreated,
|
|
288
|
+
}, { store: runStore, handler, now }));
|
|
229
289
|
}
|
|
230
290
|
// ---- WorkflowAdmin (full; Mode 2 has Postgres-native query support) ----
|
|
231
291
|
const admin = {
|
|
@@ -272,7 +332,10 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
272
332
|
// default (architecture doc §21.21). The `compensate` flag is
|
|
273
333
|
// accepted but no-ops in v1.
|
|
274
334
|
void cancelOpts?.compensate;
|
|
275
|
-
await orchestratorCancel({ runId, reason: cancelOpts?.reason }, { store: runStore, handler, now });
|
|
335
|
+
const out = await orchestratorCancel({ runId, reason: cancelOpts?.reason }, { store: runStore, handler, now });
|
|
336
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
337
|
+
concurrency.releaseRun(out.record);
|
|
338
|
+
}
|
|
276
339
|
},
|
|
277
340
|
streamRun(runId) {
|
|
278
341
|
// Live journal-event streaming is a follow-up — needs LISTEN/NOTIFY
|
|
@@ -306,6 +369,27 @@ function assertNotShutdown(shuttingDown) {
|
|
|
306
369
|
throw new Error("NodeStandaloneDriver: shutdown() has been called; new operations are refused.");
|
|
307
370
|
}
|
|
308
371
|
}
|
|
372
|
+
async function resolveConcurrencyPolicy(workflow, workflowId, environment, getManifest) {
|
|
373
|
+
if (typeof workflow !== "string" && workflow.config?.concurrency) {
|
|
374
|
+
return workflow.config.concurrency;
|
|
375
|
+
}
|
|
376
|
+
const manifest = await getManifest({ environment });
|
|
377
|
+
return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency;
|
|
378
|
+
}
|
|
379
|
+
function isTerminal(status) {
|
|
380
|
+
return (status === "completed" ||
|
|
381
|
+
status === "failed" ||
|
|
382
|
+
status === "cancelled" ||
|
|
383
|
+
status === "compensated" ||
|
|
384
|
+
status === "compensation_failed");
|
|
385
|
+
}
|
|
386
|
+
function concurrencyHolderId(args) {
|
|
387
|
+
if (args.runId !== undefined)
|
|
388
|
+
return args.runId;
|
|
389
|
+
if (args.idempotencyKey !== undefined)
|
|
390
|
+
return `idem-${args.workflowId}-${args.idempotencyKey}`;
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
309
393
|
function randomToken() {
|
|
310
394
|
return Math.floor(Math.random() * 1_000_000_000)
|
|
311
395
|
.toString(36)
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -1,34 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
export { type CronSpec, computeNextFire, nextCronFire, parseCron, toMs, } from "@voyantjs/workflows-orchestrator";
|
|
3
|
-
export interface ScheduleSource {
|
|
4
|
-
workflowId: string;
|
|
5
|
-
decl: ScheduleDeclaration;
|
|
6
|
-
}
|
|
7
|
-
export interface SchedulerDeps {
|
|
8
|
-
sources: readonly ScheduleSource[];
|
|
9
|
-
onFire: (args: {
|
|
10
|
-
workflowId: string;
|
|
11
|
-
input: unknown;
|
|
12
|
-
scheduleName?: string;
|
|
13
|
-
}) => Promise<void>;
|
|
14
|
-
now?: () => number;
|
|
15
|
-
environment?: EnvironmentName;
|
|
16
|
-
tickMs?: number;
|
|
17
|
-
setInterval?: typeof setInterval;
|
|
18
|
-
clearInterval?: typeof clearInterval;
|
|
19
|
-
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
|
|
20
|
-
}
|
|
21
|
-
export interface SchedulerHandle {
|
|
22
|
-
start: () => void;
|
|
23
|
-
stop: () => void;
|
|
24
|
-
tick: () => Promise<void>;
|
|
25
|
-
nextFirings: () => {
|
|
26
|
-
workflowId: string;
|
|
27
|
-
name?: string;
|
|
28
|
-
nextAt: number;
|
|
29
|
-
done: boolean;
|
|
30
|
-
}[];
|
|
31
|
-
sourceCount: () => number;
|
|
32
|
-
}
|
|
33
|
-
export declare function createScheduler(deps: SchedulerDeps): SchedulerHandle;
|
|
1
|
+
export { type CronSpec, computeNextFire, createScheduler, manifestScheduleSources, nextCronFire, parseCron, type SchedulableDeclaration, type SchedulerDeps, type SchedulerHandle, type ScheduleSource, toMs, } from "@voyantjs/workflows-orchestrator";
|
|
34
2
|
//# sourceMappingURL=scheduler.d.ts.map
|
package/dist/scheduler.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,QAAQ,EACb,eAAe,EACf,eAAe,EACf,uBAAuB,EACvB,YAAY,EACZ,SAAS,EACT,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,IAAI,GACL,MAAM,kCAAkC,CAAA"}
|
package/dist/scheduler.js
CHANGED
|
@@ -1,111 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { computeNextFire, nextCronFire, parseCron, toMs, } from "@voyantjs/workflows-orchestrator";
|
|
3
|
-
export function createScheduler(deps) {
|
|
4
|
-
const now = deps.now ?? (() => Date.now());
|
|
5
|
-
const tickMs = deps.tickMs ?? 1_000;
|
|
6
|
-
const setInt = deps.setInterval ?? setInterval;
|
|
7
|
-
const clearInt = deps.clearInterval ?? clearInterval;
|
|
8
|
-
const env = deps.environment ?? "development";
|
|
9
|
-
const log = deps.logger ?? (() => { });
|
|
10
|
-
const states = [];
|
|
11
|
-
for (const source of deps.sources) {
|
|
12
|
-
if (source.decl.enabled === false)
|
|
13
|
-
continue;
|
|
14
|
-
if (source.decl.environments && !source.decl.environments.includes(env))
|
|
15
|
-
continue;
|
|
16
|
-
let firstAt;
|
|
17
|
-
try {
|
|
18
|
-
firstAt = computeNextFire(source.decl, now());
|
|
19
|
-
}
|
|
20
|
-
catch (err) {
|
|
21
|
-
log("warn", `scheduler: skipping source for workflow "${source.workflowId}": ${String(err)}`);
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
states.push({ source, nextAt: firstAt, done: false, inFlight: false });
|
|
25
|
-
}
|
|
26
|
-
let timer;
|
|
27
|
-
const advanceAfterFire = (state, firedAt) => {
|
|
28
|
-
if ("at" in state.source.decl) {
|
|
29
|
-
state.done = true;
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
try {
|
|
33
|
-
state.nextAt = computeNextFire(state.source.decl, firedAt);
|
|
34
|
-
}
|
|
35
|
-
catch (err) {
|
|
36
|
-
log("error", `scheduler: cannot compute next fire for "${state.source.workflowId}": ${String(err)}`);
|
|
37
|
-
state.done = true;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
const doTick = async () => {
|
|
41
|
-
const t = now();
|
|
42
|
-
const ready = states.filter((state) => !state.done && state.nextAt <= t);
|
|
43
|
-
for (const state of ready) {
|
|
44
|
-
const overlap = state.source.decl.overlap ?? "skip";
|
|
45
|
-
if (state.inFlight && overlap === "skip")
|
|
46
|
-
continue;
|
|
47
|
-
let input;
|
|
48
|
-
try {
|
|
49
|
-
input = await resolveInput(state.source.decl.input);
|
|
50
|
-
}
|
|
51
|
-
catch (err) {
|
|
52
|
-
log("error", `scheduler: failed to resolve input for "${state.source.workflowId}": ${String(err)}`);
|
|
53
|
-
advanceAfterFire(state, t);
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
state.inFlight = true;
|
|
57
|
-
const firePromise = (async () => {
|
|
58
|
-
try {
|
|
59
|
-
await deps.onFire({
|
|
60
|
-
workflowId: state.source.workflowId,
|
|
61
|
-
input,
|
|
62
|
-
scheduleName: state.source.decl.name,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
catch (err) {
|
|
66
|
-
log("error", `scheduler: onFire threw for "${state.source.workflowId}": ${String(err)}`);
|
|
67
|
-
}
|
|
68
|
-
finally {
|
|
69
|
-
state.inFlight = false;
|
|
70
|
-
}
|
|
71
|
-
})();
|
|
72
|
-
advanceAfterFire(state, t);
|
|
73
|
-
if (overlap === "skip")
|
|
74
|
-
await firePromise;
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
return {
|
|
78
|
-
start() {
|
|
79
|
-
if (timer)
|
|
80
|
-
return;
|
|
81
|
-
timer = setInt(() => {
|
|
82
|
-
doTick().catch(() => { });
|
|
83
|
-
}, tickMs);
|
|
84
|
-
timer.unref?.();
|
|
85
|
-
},
|
|
86
|
-
stop() {
|
|
87
|
-
if (!timer)
|
|
88
|
-
return;
|
|
89
|
-
clearInt(timer);
|
|
90
|
-
timer = undefined;
|
|
91
|
-
},
|
|
92
|
-
tick: doTick,
|
|
93
|
-
nextFirings() {
|
|
94
|
-
return states.map((state) => ({
|
|
95
|
-
workflowId: state.source.workflowId,
|
|
96
|
-
name: state.source.decl.name,
|
|
97
|
-
nextAt: state.nextAt,
|
|
98
|
-
done: state.done,
|
|
99
|
-
}));
|
|
100
|
-
},
|
|
101
|
-
sourceCount() {
|
|
102
|
-
return states.length;
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
async function resolveInput(input) {
|
|
107
|
-
if (typeof input === "function") {
|
|
108
|
-
return await input();
|
|
109
|
-
}
|
|
110
|
-
return input;
|
|
111
|
-
}
|
|
1
|
+
export { computeNextFire, createScheduler, manifestScheduleSources, nextCronFire, parseCron, toMs, } from "@voyantjs/workflows-orchestrator";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/workflows-orchestrator-node",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.5",
|
|
4
4
|
"description": "Node/Docker runtime primitives for @voyantjs/workflows-orchestrator, including a file-backed run store and local scheduler.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"drizzle-orm": "^0.45.2",
|
|
31
31
|
"pg": "^8.20.0",
|
|
32
|
-
"@voyantjs/workflows-orchestrator": "0.30.
|
|
33
|
-
"@voyantjs/workflows": "0.30.
|
|
32
|
+
"@voyantjs/workflows-orchestrator": "0.30.5",
|
|
33
|
+
"@voyantjs/workflows": "0.30.5"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^20.12.0",
|
|
@@ -37,11 +37,17 @@ import { deriveStableEventId } from "@voyantjs/workflows/events"
|
|
|
37
37
|
import { handleStepRequest, type WorkflowStepRequest } from "@voyantjs/workflows/handler"
|
|
38
38
|
import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
|
|
39
39
|
import {
|
|
40
|
+
createInProcessConcurrencyCoordinator,
|
|
41
|
+
createScheduler,
|
|
42
|
+
manifestScheduleSources,
|
|
40
43
|
cancel as orchestratorCancel,
|
|
41
44
|
trigger as orchestratorTrigger,
|
|
42
45
|
type RunRecord,
|
|
46
|
+
type RuntimeConcurrencyPolicy,
|
|
43
47
|
routeEvent,
|
|
48
|
+
type SchedulerHandle,
|
|
44
49
|
type StepHandler,
|
|
50
|
+
type TriggerArgs,
|
|
45
51
|
} from "@voyantjs/workflows-orchestrator"
|
|
46
52
|
import type { drizzle } from "drizzle-orm/node-postgres"
|
|
47
53
|
|
|
@@ -105,6 +111,10 @@ export interface NodeStandaloneDriverOptions {
|
|
|
105
111
|
* cadence. Defaults to `false` (poller starts immediately).
|
|
106
112
|
*/
|
|
107
113
|
disableTimeWheel?: boolean
|
|
114
|
+
/** Schedule runner tick interval. Defaults to 1_000 ms. */
|
|
115
|
+
schedulePollIntervalMs?: number
|
|
116
|
+
/** Disable automatic firing for schedules registered through manifests. */
|
|
117
|
+
disableScheduleRunner?: boolean
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
const DEFAULT_TENANT_META: RunRecord["tenantMeta"] = {
|
|
@@ -189,7 +199,16 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
189
199
|
wakeupManager.start()
|
|
190
200
|
}
|
|
191
201
|
|
|
202
|
+
const scheduleRunners = new Map<string, SchedulerHandle>()
|
|
192
203
|
let shuttingDown = false
|
|
204
|
+
const concurrency = createInProcessConcurrencyCoordinator({
|
|
205
|
+
async cancelRun(runId, reason) {
|
|
206
|
+
const out = await orchestratorCancel({ runId, reason }, { store: runStore, handler, now })
|
|
207
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
208
|
+
concurrency.releaseRun(out.record)
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
})
|
|
193
212
|
|
|
194
213
|
// ---- WorkflowDriver implementation ----
|
|
195
214
|
|
|
@@ -216,6 +235,10 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
216
235
|
error: err instanceof Error ? err.message : String(err),
|
|
217
236
|
})
|
|
218
237
|
}
|
|
238
|
+
startScheduleRunner(
|
|
239
|
+
args.environment as "production" | "preview" | "development",
|
|
240
|
+
args.manifest,
|
|
241
|
+
)
|
|
219
242
|
return result
|
|
220
243
|
}
|
|
221
244
|
|
|
@@ -233,8 +256,9 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
233
256
|
assertNotShutdown(shuttingDown)
|
|
234
257
|
const workflowId = typeof workflow === "string" ? workflow : workflow.id
|
|
235
258
|
const env = triggerOpts?.environment ?? defaultEnv
|
|
259
|
+
const policy = await resolveConcurrencyPolicy(workflow, workflowId, env, getManifest)
|
|
236
260
|
|
|
237
|
-
const record = await
|
|
261
|
+
const record = await triggerRecord(
|
|
238
262
|
{
|
|
239
263
|
workflowId,
|
|
240
264
|
workflowVersion: triggerOpts?.lockToVersion ?? "v1",
|
|
@@ -246,7 +270,7 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
246
270
|
delay: triggerOpts?.delay,
|
|
247
271
|
priority: triggerOpts?.priority,
|
|
248
272
|
},
|
|
249
|
-
|
|
273
|
+
policy,
|
|
250
274
|
)
|
|
251
275
|
// Sync wakeup row so the time-wheel can resume DATETIME-parked runs.
|
|
252
276
|
// No-op if the run completed inline (status !== "waiting").
|
|
@@ -287,7 +311,7 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
287
311
|
continue
|
|
288
312
|
}
|
|
289
313
|
try {
|
|
290
|
-
const record = await
|
|
314
|
+
const record = await triggerRecord(
|
|
291
315
|
{
|
|
292
316
|
workflowId: entry.targetWorkflowId,
|
|
293
317
|
workflowVersion: "v1",
|
|
@@ -302,7 +326,12 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
302
326
|
filterId: entry.filterId,
|
|
303
327
|
},
|
|
304
328
|
},
|
|
305
|
-
|
|
329
|
+
await resolveConcurrencyPolicy(
|
|
330
|
+
entry.targetWorkflowId,
|
|
331
|
+
entry.targetWorkflowId,
|
|
332
|
+
args.environment,
|
|
333
|
+
getManifest,
|
|
334
|
+
),
|
|
306
335
|
)
|
|
307
336
|
await syncWakeupFromRecord(wakeupStore, record)
|
|
308
337
|
matches.push({
|
|
@@ -340,6 +369,72 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
340
369
|
// Idempotent — calling stop() on an already-stopped manager is a
|
|
341
370
|
// no-op.
|
|
342
371
|
wakeupManager.stop()
|
|
372
|
+
for (const runner of scheduleRunners.values()) {
|
|
373
|
+
runner.stop()
|
|
374
|
+
}
|
|
375
|
+
scheduleRunners.clear()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function startScheduleRunner(
|
|
379
|
+
environment: "production" | "preview" | "development",
|
|
380
|
+
manifest: WorkflowManifest,
|
|
381
|
+
): void {
|
|
382
|
+
const existing = scheduleRunners.get(environment)
|
|
383
|
+
existing?.stop()
|
|
384
|
+
scheduleRunners.delete(environment)
|
|
385
|
+
if (opts.disableScheduleRunner) return
|
|
386
|
+
|
|
387
|
+
const sources = manifestScheduleSources(manifest)
|
|
388
|
+
if (sources.length === 0) return
|
|
389
|
+
|
|
390
|
+
const runner = createScheduler({
|
|
391
|
+
sources,
|
|
392
|
+
environment,
|
|
393
|
+
now,
|
|
394
|
+
tickMs: opts.schedulePollIntervalMs,
|
|
395
|
+
onFire: async ({ workflowId, input, scheduleId, fireAt }) => {
|
|
396
|
+
assertNotShutdown(shuttingDown)
|
|
397
|
+
const record = await triggerRecord(
|
|
398
|
+
{
|
|
399
|
+
workflowId,
|
|
400
|
+
workflowVersion: "v1",
|
|
401
|
+
input,
|
|
402
|
+
tenantMeta,
|
|
403
|
+
environment,
|
|
404
|
+
idempotencyKey: `${scheduleId}:${fireAt}`,
|
|
405
|
+
triggeredBy: { kind: "schedule", scheduleId },
|
|
406
|
+
},
|
|
407
|
+
await resolveConcurrencyPolicy(workflowId, workflowId, environment, getManifest),
|
|
408
|
+
)
|
|
409
|
+
await syncWakeupFromRecord(wakeupStore, record)
|
|
410
|
+
},
|
|
411
|
+
logger: (level, msg, data) => deps.logger(level, msg, data),
|
|
412
|
+
})
|
|
413
|
+
if (runner.sourceCount() === 0) return
|
|
414
|
+
runner.start()
|
|
415
|
+
scheduleRunners.set(environment, runner)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function triggerRecord(
|
|
419
|
+
args: TriggerArgs,
|
|
420
|
+
policy: RuntimeConcurrencyPolicy | undefined,
|
|
421
|
+
): Promise<RunRecord> {
|
|
422
|
+
return concurrency.run(
|
|
423
|
+
{
|
|
424
|
+
workflowId: args.workflowId,
|
|
425
|
+
input: args.input,
|
|
426
|
+
policy,
|
|
427
|
+
holderId: concurrencyHolderId(args),
|
|
428
|
+
},
|
|
429
|
+
(hooks) =>
|
|
430
|
+
orchestratorTrigger(
|
|
431
|
+
{
|
|
432
|
+
...args,
|
|
433
|
+
onRunRecordCreated: hooks.onRunRecordCreated,
|
|
434
|
+
},
|
|
435
|
+
{ store: runStore, handler, now },
|
|
436
|
+
),
|
|
437
|
+
)
|
|
343
438
|
}
|
|
344
439
|
|
|
345
440
|
// ---- WorkflowAdmin (full; Mode 2 has Postgres-native query support) ----
|
|
@@ -387,10 +482,13 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
387
482
|
// default (architecture doc §21.21). The `compensate` flag is
|
|
388
483
|
// accepted but no-ops in v1.
|
|
389
484
|
void cancelOpts?.compensate
|
|
390
|
-
await orchestratorCancel(
|
|
485
|
+
const out = await orchestratorCancel(
|
|
391
486
|
{ runId, reason: cancelOpts?.reason },
|
|
392
487
|
{ store: runStore, handler, now },
|
|
393
488
|
)
|
|
489
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
490
|
+
concurrency.releaseRun(out.record)
|
|
491
|
+
}
|
|
394
492
|
},
|
|
395
493
|
|
|
396
494
|
streamRun(runId: string): AsyncIterable<never> {
|
|
@@ -429,6 +527,35 @@ function assertNotShutdown(shuttingDown: boolean): void {
|
|
|
429
527
|
}
|
|
430
528
|
}
|
|
431
529
|
|
|
530
|
+
async function resolveConcurrencyPolicy(
|
|
531
|
+
workflow: { id: string; config?: { concurrency?: RuntimeConcurrencyPolicy } } | string,
|
|
532
|
+
workflowId: string,
|
|
533
|
+
environment: "production" | "preview" | "development",
|
|
534
|
+
getManifest: (args: { environment: string }) => Promise<WorkflowManifest | null>,
|
|
535
|
+
): Promise<RuntimeConcurrencyPolicy | undefined> {
|
|
536
|
+
if (typeof workflow !== "string" && workflow.config?.concurrency) {
|
|
537
|
+
return workflow.config.concurrency
|
|
538
|
+
}
|
|
539
|
+
const manifest = await getManifest({ environment })
|
|
540
|
+
return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isTerminal(status: RunRecord["status"]): boolean {
|
|
544
|
+
return (
|
|
545
|
+
status === "completed" ||
|
|
546
|
+
status === "failed" ||
|
|
547
|
+
status === "cancelled" ||
|
|
548
|
+
status === "compensated" ||
|
|
549
|
+
status === "compensation_failed"
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function concurrencyHolderId(args: TriggerArgs): string | undefined {
|
|
554
|
+
if (args.runId !== undefined) return args.runId
|
|
555
|
+
if (args.idempotencyKey !== undefined) return `idem-${args.workflowId}-${args.idempotencyKey}`
|
|
556
|
+
return undefined
|
|
557
|
+
}
|
|
558
|
+
|
|
432
559
|
function randomToken(): string {
|
|
433
560
|
return Math.floor(Math.random() * 1_000_000_000)
|
|
434
561
|
.toString(36)
|
package/src/scheduler.ts
CHANGED
|
@@ -1,154 +1,13 @@
|
|
|
1
|
-
import type { EnvironmentName, ScheduleDeclaration } from "@voyantjs/workflows"
|
|
2
|
-
import { computeNextFire } from "@voyantjs/workflows-orchestrator"
|
|
3
|
-
|
|
4
1
|
export {
|
|
5
2
|
type CronSpec,
|
|
6
3
|
computeNextFire,
|
|
4
|
+
createScheduler,
|
|
5
|
+
manifestScheduleSources,
|
|
7
6
|
nextCronFire,
|
|
8
7
|
parseCron,
|
|
8
|
+
type SchedulableDeclaration,
|
|
9
|
+
type SchedulerDeps,
|
|
10
|
+
type SchedulerHandle,
|
|
11
|
+
type ScheduleSource,
|
|
9
12
|
toMs,
|
|
10
13
|
} from "@voyantjs/workflows-orchestrator"
|
|
11
|
-
|
|
12
|
-
export interface ScheduleSource {
|
|
13
|
-
workflowId: string
|
|
14
|
-
decl: ScheduleDeclaration
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface SchedulerDeps {
|
|
18
|
-
sources: readonly ScheduleSource[]
|
|
19
|
-
onFire: (args: { workflowId: string; input: unknown; scheduleName?: string }) => Promise<void>
|
|
20
|
-
now?: () => number
|
|
21
|
-
environment?: EnvironmentName
|
|
22
|
-
tickMs?: number
|
|
23
|
-
setInterval?: typeof setInterval
|
|
24
|
-
clearInterval?: typeof clearInterval
|
|
25
|
-
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SchedulerHandle {
|
|
29
|
-
start: () => void
|
|
30
|
-
stop: () => void
|
|
31
|
-
tick: () => Promise<void>
|
|
32
|
-
nextFirings: () => { workflowId: string; name?: string; nextAt: number; done: boolean }[]
|
|
33
|
-
sourceCount: () => number
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface SourceState {
|
|
37
|
-
source: ScheduleSource
|
|
38
|
-
nextAt: number
|
|
39
|
-
done: boolean
|
|
40
|
-
inFlight: boolean
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function createScheduler(deps: SchedulerDeps): SchedulerHandle {
|
|
44
|
-
const now = deps.now ?? (() => Date.now())
|
|
45
|
-
const tickMs = deps.tickMs ?? 1_000
|
|
46
|
-
const setInt = deps.setInterval ?? setInterval
|
|
47
|
-
const clearInt = deps.clearInterval ?? clearInterval
|
|
48
|
-
const env = deps.environment ?? "development"
|
|
49
|
-
const log = deps.logger ?? (() => {})
|
|
50
|
-
|
|
51
|
-
const states: SourceState[] = []
|
|
52
|
-
for (const source of deps.sources) {
|
|
53
|
-
if (source.decl.enabled === false) continue
|
|
54
|
-
if (source.decl.environments && !source.decl.environments.includes(env)) continue
|
|
55
|
-
let firstAt: number
|
|
56
|
-
try {
|
|
57
|
-
firstAt = computeNextFire(source.decl, now())
|
|
58
|
-
} catch (err) {
|
|
59
|
-
log("warn", `scheduler: skipping source for workflow "${source.workflowId}": ${String(err)}`)
|
|
60
|
-
continue
|
|
61
|
-
}
|
|
62
|
-
states.push({ source, nextAt: firstAt, done: false, inFlight: false })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
let timer: ReturnType<typeof setInterval> | undefined
|
|
66
|
-
|
|
67
|
-
const advanceAfterFire = (state: SourceState, firedAt: number): void => {
|
|
68
|
-
if ("at" in state.source.decl) {
|
|
69
|
-
state.done = true
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
state.nextAt = computeNextFire(state.source.decl, firedAt)
|
|
74
|
-
} catch (err) {
|
|
75
|
-
log(
|
|
76
|
-
"error",
|
|
77
|
-
`scheduler: cannot compute next fire for "${state.source.workflowId}": ${String(err)}`,
|
|
78
|
-
)
|
|
79
|
-
state.done = true
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const doTick = async (): Promise<void> => {
|
|
84
|
-
const t = now()
|
|
85
|
-
const ready = states.filter((state) => !state.done && state.nextAt <= t)
|
|
86
|
-
for (const state of ready) {
|
|
87
|
-
const overlap = state.source.decl.overlap ?? "skip"
|
|
88
|
-
if (state.inFlight && overlap === "skip") continue
|
|
89
|
-
let input: unknown
|
|
90
|
-
try {
|
|
91
|
-
input = await resolveInput(state.source.decl.input)
|
|
92
|
-
} catch (err) {
|
|
93
|
-
log(
|
|
94
|
-
"error",
|
|
95
|
-
`scheduler: failed to resolve input for "${state.source.workflowId}": ${String(err)}`,
|
|
96
|
-
)
|
|
97
|
-
advanceAfterFire(state, t)
|
|
98
|
-
continue
|
|
99
|
-
}
|
|
100
|
-
state.inFlight = true
|
|
101
|
-
const firePromise = (async () => {
|
|
102
|
-
try {
|
|
103
|
-
await deps.onFire({
|
|
104
|
-
workflowId: state.source.workflowId,
|
|
105
|
-
input,
|
|
106
|
-
scheduleName: state.source.decl.name,
|
|
107
|
-
})
|
|
108
|
-
} catch (err) {
|
|
109
|
-
log("error", `scheduler: onFire threw for "${state.source.workflowId}": ${String(err)}`)
|
|
110
|
-
} finally {
|
|
111
|
-
state.inFlight = false
|
|
112
|
-
}
|
|
113
|
-
})()
|
|
114
|
-
advanceAfterFire(state, t)
|
|
115
|
-
if (overlap === "skip") await firePromise
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
start() {
|
|
121
|
-
if (timer) return
|
|
122
|
-
timer = setInt(() => {
|
|
123
|
-
doTick().catch(() => {})
|
|
124
|
-
}, tickMs)
|
|
125
|
-
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
126
|
-
},
|
|
127
|
-
stop() {
|
|
128
|
-
if (!timer) return
|
|
129
|
-
clearInt(timer)
|
|
130
|
-
timer = undefined
|
|
131
|
-
},
|
|
132
|
-
tick: doTick,
|
|
133
|
-
nextFirings() {
|
|
134
|
-
return states.map((state) => ({
|
|
135
|
-
workflowId: state.source.workflowId,
|
|
136
|
-
name: state.source.decl.name,
|
|
137
|
-
nextAt: state.nextAt,
|
|
138
|
-
done: state.done,
|
|
139
|
-
}))
|
|
140
|
-
},
|
|
141
|
-
sourceCount() {
|
|
142
|
-
return states.length
|
|
143
|
-
},
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function resolveInput(
|
|
148
|
-
input: unknown | (() => unknown | Promise<unknown>) | undefined,
|
|
149
|
-
): Promise<unknown> {
|
|
150
|
-
if (typeof input === "function") {
|
|
151
|
-
return await (input as () => unknown | Promise<unknown>)()
|
|
152
|
-
}
|
|
153
|
-
return input
|
|
154
|
-
}
|