@voyantjs/workflows-orchestrator-node 0.30.1 → 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 +91 -6
- package/dist/postgres-schema.d.ts +17 -0
- package/dist/postgres-schema.d.ts.map +1 -1
- package/dist/postgres-schema.js +2 -1
- package/dist/postgres-wakeup-store.d.ts +1 -0
- package/dist/postgres-wakeup-store.d.ts.map +1 -1
- package/dist/postgres-wakeup-store.js +11 -2
- package/dist/scheduler.d.ts +1 -43
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +1 -207
- package/dist/wakeup-store.d.ts +1 -0
- package/dist/wakeup-store.d.ts.map +1 -1
- package/dist/wakeup-store.js +8 -1
- package/drizzle/0005_wakeup_priority.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/node-standalone-driver.ts +133 -5
- package/src/postgres-schema.ts +2 -1
- package/src/postgres-wakeup-store.ts +12 -2
- package/src/scheduler.ts +13 -250
- package/src/wakeup-store.ts +9 -1
|
@@ -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,
|
|
@@ -138,7 +149,8 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
138
149
|
tags: triggerOpts?.tags,
|
|
139
150
|
idempotencyKey: triggerOpts?.idempotencyKey,
|
|
140
151
|
delay: triggerOpts?.delay,
|
|
141
|
-
|
|
152
|
+
priority: triggerOpts?.priority,
|
|
153
|
+
}, policy);
|
|
142
154
|
// Sync wakeup row so the time-wheel can resume DATETIME-parked runs.
|
|
143
155
|
// No-op if the run completed inline (status !== "waiting").
|
|
144
156
|
await syncWakeupFromRecord(wakeupStore, record);
|
|
@@ -176,7 +188,7 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
176
188
|
continue;
|
|
177
189
|
}
|
|
178
190
|
try {
|
|
179
|
-
const record = await
|
|
191
|
+
const record = await triggerRecord({
|
|
180
192
|
workflowId: entry.targetWorkflowId,
|
|
181
193
|
workflowVersion: "v1",
|
|
182
194
|
input: entry.input,
|
|
@@ -189,7 +201,7 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
189
201
|
eventType: args.envelope.name,
|
|
190
202
|
filterId: entry.filterId,
|
|
191
203
|
},
|
|
192
|
-
},
|
|
204
|
+
}, await resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, getManifest));
|
|
193
205
|
await syncWakeupFromRecord(wakeupStore, record);
|
|
194
206
|
matches.push({
|
|
195
207
|
filterId: entry.filterId,
|
|
@@ -225,6 +237,55 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
225
237
|
// Idempotent — calling stop() on an already-stopped manager is a
|
|
226
238
|
// no-op.
|
|
227
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 }));
|
|
228
289
|
}
|
|
229
290
|
// ---- WorkflowAdmin (full; Mode 2 has Postgres-native query support) ----
|
|
230
291
|
const admin = {
|
|
@@ -271,7 +332,10 @@ export function createNodeStandaloneDriver(opts) {
|
|
|
271
332
|
// default (architecture doc §21.21). The `compensate` flag is
|
|
272
333
|
// accepted but no-ops in v1.
|
|
273
334
|
void cancelOpts?.compensate;
|
|
274
|
-
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
|
+
}
|
|
275
339
|
},
|
|
276
340
|
streamRun(runId) {
|
|
277
341
|
// Live journal-event streaming is a follow-up — needs LISTEN/NOTIFY
|
|
@@ -305,6 +369,27 @@ function assertNotShutdown(shuttingDown) {
|
|
|
305
369
|
throw new Error("NodeStandaloneDriver: shutdown() has been called; new operations are refused.");
|
|
306
370
|
}
|
|
307
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
|
+
}
|
|
308
393
|
function randomToken() {
|
|
309
394
|
return Math.floor(Math.random() * 1_000_000_000)
|
|
310
395
|
.toString(36)
|
|
@@ -272,6 +272,23 @@ export declare const wakeupsTable: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
272
272
|
identity: undefined;
|
|
273
273
|
generated: undefined;
|
|
274
274
|
}, {}, {}>;
|
|
275
|
+
priority: import("drizzle-orm/pg-core").PgColumn<{
|
|
276
|
+
name: "priority";
|
|
277
|
+
tableName: "voyant_wakeups";
|
|
278
|
+
dataType: "number";
|
|
279
|
+
columnType: "PgInteger";
|
|
280
|
+
data: number;
|
|
281
|
+
driverParam: string | number;
|
|
282
|
+
notNull: true;
|
|
283
|
+
hasDefault: true;
|
|
284
|
+
isPrimaryKey: false;
|
|
285
|
+
isAutoincrement: false;
|
|
286
|
+
hasRuntimeDefault: false;
|
|
287
|
+
enumValues: undefined;
|
|
288
|
+
baseColumn: never;
|
|
289
|
+
identity: undefined;
|
|
290
|
+
generated: undefined;
|
|
291
|
+
}, {}, {}>;
|
|
275
292
|
leaseOwner: import("drizzle-orm/pg-core").PgColumn<{
|
|
276
293
|
name: "lease_owner";
|
|
277
294
|
tableName: "voyant_wakeups";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-schema.d.ts","sourceRoot":"","sources":["../src/postgres-schema.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0C7B,CAAA;AAED,eAAO,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"postgres-schema.d.ts","sourceRoot":"","sources":["../src/postgres-schema.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0C7B,CAAA;AAED,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAcxB,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAelC,CAAA"}
|
package/dist/postgres-schema.js
CHANGED
|
@@ -36,11 +36,12 @@ export const snapshotRunsTable = pgTable("voyant_snapshot_runs", {
|
|
|
36
36
|
export const wakeupsTable = pgTable("voyant_wakeups", {
|
|
37
37
|
runId: text("run_id").primaryKey(),
|
|
38
38
|
wakeAt: bigint("wake_at", { mode: "number" }).notNull(),
|
|
39
|
+
priority: integer("priority").notNull().default(0),
|
|
39
40
|
leaseOwner: text("lease_owner"),
|
|
40
41
|
leaseExpiresAt: bigint("lease_expires_at", { mode: "number" }),
|
|
41
42
|
updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
|
|
42
43
|
}, (table) => ({
|
|
43
|
-
dueIdx: index("voyant_wakeups_due_idx").on(table.wakeAt),
|
|
44
|
+
dueIdx: index("voyant_wakeups_due_idx").on(table.priority.desc(), table.wakeAt.asc()),
|
|
44
45
|
leaseIdx: index("voyant_wakeups_lease_idx").on(table.leaseExpiresAt),
|
|
45
46
|
}));
|
|
46
47
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-wakeup-store.d.ts","sourceRoot":"","sources":["../src/postgres-wakeup-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAElE,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAE1C,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,QAAQ,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,0BAA0B,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"postgres-wakeup-store.d.ts","sourceRoot":"","sources":["../src/postgres-wakeup-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAElE,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAE1C,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,QAAQ,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,0BAA0B,GAAG,WAAW,CAkHvF;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,EAC/D,SAAS,EAAE,MAAM;;;;;;;EAUlB;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,YAAY,CAAC,YAAY,GAAG,YAAY,CASrF"}
|
|
@@ -49,7 +49,7 @@ export function createPostgresWakeupStore(opts) {
|
|
|
49
49
|
OR lease_expires_at <= ${at}
|
|
50
50
|
OR lease_owner = ${owner}
|
|
51
51
|
)
|
|
52
|
-
ORDER BY wake_at ASC
|
|
52
|
+
ORDER BY priority DESC, wake_at ASC
|
|
53
53
|
LIMIT ${limit}
|
|
54
54
|
FOR UPDATE SKIP LOCKED
|
|
55
55
|
)
|
|
@@ -59,12 +59,19 @@ export function createPostgresWakeupStore(opts) {
|
|
|
59
59
|
updated_at = ${at}
|
|
60
60
|
FROM due
|
|
61
61
|
WHERE wakeups.run_id = due.run_id
|
|
62
|
-
RETURNING wakeups.run_id, wakeups.wake_at, wakeups.lease_owner, wakeups.lease_expires_at, wakeups.updated_at
|
|
62
|
+
RETURNING wakeups.run_id, wakeups.wake_at, wakeups.priority, wakeups.lease_owner, wakeups.lease_expires_at, wakeups.updated_at
|
|
63
63
|
`);
|
|
64
64
|
const rows = result.rows;
|
|
65
|
+
rows.sort((a, b) => {
|
|
66
|
+
const priorityDelta = toNumber(b.priority) - toNumber(a.priority);
|
|
67
|
+
if (priorityDelta !== 0)
|
|
68
|
+
return priorityDelta;
|
|
69
|
+
return toNumber(a.wake_at) - toNumber(b.wake_at);
|
|
70
|
+
});
|
|
65
71
|
return rows.map((row) => ({
|
|
66
72
|
runId: row.run_id,
|
|
67
73
|
wakeAt: toNumber(row.wake_at),
|
|
74
|
+
priority: toNumber(row.priority),
|
|
68
75
|
leaseOwner: row.lease_owner ?? undefined,
|
|
69
76
|
leaseExpiresAt: row.lease_expires_at === null ? undefined : toNumber(row.lease_expires_at),
|
|
70
77
|
updatedAt: toNumber(row.updated_at),
|
|
@@ -90,6 +97,7 @@ export function wakeupToRow(record, updatedAt) {
|
|
|
90
97
|
return {
|
|
91
98
|
runId: record.runId,
|
|
92
99
|
wakeAt: record.wakeAt,
|
|
100
|
+
priority: record.priority ?? 0,
|
|
93
101
|
leaseOwner: record.leaseOwner ?? null,
|
|
94
102
|
leaseExpiresAt: record.leaseExpiresAt ?? null,
|
|
95
103
|
updatedAt,
|
|
@@ -99,6 +107,7 @@ export function rowToWakeupRecord(row) {
|
|
|
99
107
|
return {
|
|
100
108
|
runId: row.runId,
|
|
101
109
|
wakeAt: row.wakeAt,
|
|
110
|
+
priority: row.priority,
|
|
102
111
|
leaseOwner: row.leaseOwner ?? undefined,
|
|
103
112
|
leaseExpiresAt: row.leaseExpiresAt ?? undefined,
|
|
104
113
|
updatedAt: row.updatedAt,
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -1,44 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
export interface ScheduleSource {
|
|
3
|
-
workflowId: string;
|
|
4
|
-
decl: ScheduleDeclaration;
|
|
5
|
-
}
|
|
6
|
-
export interface SchedulerDeps {
|
|
7
|
-
sources: readonly ScheduleSource[];
|
|
8
|
-
onFire: (args: {
|
|
9
|
-
workflowId: string;
|
|
10
|
-
input: unknown;
|
|
11
|
-
scheduleName?: string;
|
|
12
|
-
}) => Promise<void>;
|
|
13
|
-
now?: () => number;
|
|
14
|
-
environment?: EnvironmentName;
|
|
15
|
-
tickMs?: number;
|
|
16
|
-
setInterval?: typeof setInterval;
|
|
17
|
-
clearInterval?: typeof clearInterval;
|
|
18
|
-
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
|
|
19
|
-
}
|
|
20
|
-
export interface SchedulerHandle {
|
|
21
|
-
start: () => void;
|
|
22
|
-
stop: () => void;
|
|
23
|
-
tick: () => Promise<void>;
|
|
24
|
-
nextFirings: () => {
|
|
25
|
-
workflowId: string;
|
|
26
|
-
name?: string;
|
|
27
|
-
nextAt: number;
|
|
28
|
-
done: boolean;
|
|
29
|
-
}[];
|
|
30
|
-
sourceCount: () => number;
|
|
31
|
-
}
|
|
32
|
-
export declare function createScheduler(deps: SchedulerDeps): SchedulerHandle;
|
|
33
|
-
export declare function computeNextFire(decl: ScheduleDeclaration, fromMs: number): number;
|
|
34
|
-
export interface CronSpec {
|
|
35
|
-
minute: number[];
|
|
36
|
-
hour: number[];
|
|
37
|
-
day: number[];
|
|
38
|
-
month: number[];
|
|
39
|
-
dow: number[];
|
|
40
|
-
}
|
|
41
|
-
export declare function parseCron(expr: string): CronSpec;
|
|
42
|
-
export declare function nextCronFire(spec: CronSpec, fromMs: number): number;
|
|
43
|
-
export declare function toMs(duration: Duration): number;
|
|
1
|
+
export { type CronSpec, computeNextFire, createScheduler, manifestScheduleSources, nextCronFire, parseCron, type SchedulableDeclaration, type SchedulerDeps, type SchedulerHandle, type ScheduleSource, toMs, } from "@voyantjs/workflows-orchestrator";
|
|
44
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,KAAK,
|
|
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,207 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
const now = deps.now ?? (() => Date.now());
|
|
3
|
-
const tickMs = deps.tickMs ?? 1_000;
|
|
4
|
-
const setInt = deps.setInterval ?? setInterval;
|
|
5
|
-
const clearInt = deps.clearInterval ?? clearInterval;
|
|
6
|
-
const env = deps.environment ?? "development";
|
|
7
|
-
const log = deps.logger ?? (() => { });
|
|
8
|
-
const states = [];
|
|
9
|
-
for (const source of deps.sources) {
|
|
10
|
-
if (source.decl.enabled === false)
|
|
11
|
-
continue;
|
|
12
|
-
if (source.decl.environments && !source.decl.environments.includes(env))
|
|
13
|
-
continue;
|
|
14
|
-
let firstAt;
|
|
15
|
-
try {
|
|
16
|
-
firstAt = computeNextFire(source.decl, now());
|
|
17
|
-
}
|
|
18
|
-
catch (err) {
|
|
19
|
-
log("warn", `scheduler: skipping source for workflow "${source.workflowId}": ${String(err)}`);
|
|
20
|
-
continue;
|
|
21
|
-
}
|
|
22
|
-
states.push({ source, nextAt: firstAt, done: false, inFlight: false });
|
|
23
|
-
}
|
|
24
|
-
let timer;
|
|
25
|
-
const advanceAfterFire = (state, firedAt) => {
|
|
26
|
-
if ("at" in state.source.decl) {
|
|
27
|
-
state.done = true;
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
state.nextAt = computeNextFire(state.source.decl, firedAt);
|
|
32
|
-
}
|
|
33
|
-
catch (err) {
|
|
34
|
-
log("error", `scheduler: cannot compute next fire for "${state.source.workflowId}": ${String(err)}`);
|
|
35
|
-
state.done = true;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
const doTick = async () => {
|
|
39
|
-
const t = now();
|
|
40
|
-
const ready = states.filter((state) => !state.done && state.nextAt <= t);
|
|
41
|
-
for (const state of ready) {
|
|
42
|
-
const overlap = state.source.decl.overlap ?? "skip";
|
|
43
|
-
if (state.inFlight && overlap === "skip")
|
|
44
|
-
continue;
|
|
45
|
-
let input;
|
|
46
|
-
try {
|
|
47
|
-
input = await resolveInput(state.source.decl.input);
|
|
48
|
-
}
|
|
49
|
-
catch (err) {
|
|
50
|
-
log("error", `scheduler: failed to resolve input for "${state.source.workflowId}": ${String(err)}`);
|
|
51
|
-
advanceAfterFire(state, t);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
state.inFlight = true;
|
|
55
|
-
const firePromise = (async () => {
|
|
56
|
-
try {
|
|
57
|
-
await deps.onFire({
|
|
58
|
-
workflowId: state.source.workflowId,
|
|
59
|
-
input,
|
|
60
|
-
scheduleName: state.source.decl.name,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
log("error", `scheduler: onFire threw for "${state.source.workflowId}": ${String(err)}`);
|
|
65
|
-
}
|
|
66
|
-
finally {
|
|
67
|
-
state.inFlight = false;
|
|
68
|
-
}
|
|
69
|
-
})();
|
|
70
|
-
advanceAfterFire(state, t);
|
|
71
|
-
if (overlap === "skip")
|
|
72
|
-
await firePromise;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
return {
|
|
76
|
-
start() {
|
|
77
|
-
if (timer)
|
|
78
|
-
return;
|
|
79
|
-
timer = setInt(() => {
|
|
80
|
-
doTick().catch(() => { });
|
|
81
|
-
}, tickMs);
|
|
82
|
-
timer.unref?.();
|
|
83
|
-
},
|
|
84
|
-
stop() {
|
|
85
|
-
if (!timer)
|
|
86
|
-
return;
|
|
87
|
-
clearInt(timer);
|
|
88
|
-
timer = undefined;
|
|
89
|
-
},
|
|
90
|
-
tick: doTick,
|
|
91
|
-
nextFirings() {
|
|
92
|
-
return states.map((state) => ({
|
|
93
|
-
workflowId: state.source.workflowId,
|
|
94
|
-
name: state.source.decl.name,
|
|
95
|
-
nextAt: state.nextAt,
|
|
96
|
-
done: state.done,
|
|
97
|
-
}));
|
|
98
|
-
},
|
|
99
|
-
sourceCount() {
|
|
100
|
-
return states.length;
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
async function resolveInput(input) {
|
|
105
|
-
if (typeof input === "function") {
|
|
106
|
-
return await input();
|
|
107
|
-
}
|
|
108
|
-
return input;
|
|
109
|
-
}
|
|
110
|
-
export function computeNextFire(decl, fromMs) {
|
|
111
|
-
if ("cron" in decl)
|
|
112
|
-
return nextCronFire(parseCron(decl.cron), fromMs);
|
|
113
|
-
if ("every" in decl)
|
|
114
|
-
return fromMs + toMs(decl.every);
|
|
115
|
-
if ("at" in decl) {
|
|
116
|
-
const at = typeof decl.at === "string" ? Date.parse(decl.at) : decl.at.getTime();
|
|
117
|
-
if (!Number.isFinite(at))
|
|
118
|
-
throw new Error(`invalid "at" value: ${String(decl.at)}`);
|
|
119
|
-
return at < fromMs ? Number.POSITIVE_INFINITY : at;
|
|
120
|
-
}
|
|
121
|
-
throw new Error(`schedule declaration missing one of cron/every/at`);
|
|
122
|
-
}
|
|
123
|
-
export function parseCron(expr) {
|
|
124
|
-
const parts = expr.trim().split(/\s+/);
|
|
125
|
-
if (parts.length !== 5) {
|
|
126
|
-
throw new Error(`invalid cron "${expr}" — expected 5 fields (minute hour day month dow)`);
|
|
127
|
-
}
|
|
128
|
-
return {
|
|
129
|
-
minute: parseField(parts[0], 0, 59, "minute"),
|
|
130
|
-
hour: parseField(parts[1], 0, 23, "hour"),
|
|
131
|
-
day: parseField(parts[2], 1, 31, "day"),
|
|
132
|
-
month: parseField(parts[3], 1, 12, "month"),
|
|
133
|
-
dow: parseField(parts[4], 0, 6, "dow"),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
function parseField(f, min, max, label) {
|
|
137
|
-
const out = new Set();
|
|
138
|
-
for (const part of f.split(",")) {
|
|
139
|
-
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
|
|
140
|
-
const body = stepMatch ? stepMatch[1] : part;
|
|
141
|
-
const step = stepMatch ? Number(stepMatch[2]) : 1;
|
|
142
|
-
if (!(step >= 1))
|
|
143
|
-
throw new Error(`cron ${label} step must be >=1 in "${f}"`);
|
|
144
|
-
let lo;
|
|
145
|
-
let hi;
|
|
146
|
-
if (body === "*") {
|
|
147
|
-
lo = min;
|
|
148
|
-
hi = max;
|
|
149
|
-
}
|
|
150
|
-
else if (body.includes("-")) {
|
|
151
|
-
const [a, b] = body.split("-");
|
|
152
|
-
lo = Number(a);
|
|
153
|
-
hi = Number(b);
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
lo = Number(body);
|
|
157
|
-
hi = lo;
|
|
158
|
-
}
|
|
159
|
-
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo < min || hi > max || lo > hi) {
|
|
160
|
-
throw new Error(`cron ${label} out of range [${min}..${max}] in "${f}"`);
|
|
161
|
-
}
|
|
162
|
-
for (let i = lo; i <= hi; i += step)
|
|
163
|
-
out.add(i);
|
|
164
|
-
}
|
|
165
|
-
return [...out].sort((a, b) => a - b);
|
|
166
|
-
}
|
|
167
|
-
export function nextCronFire(spec, fromMs) {
|
|
168
|
-
const date = new Date(fromMs);
|
|
169
|
-
date.setUTCSeconds(0, 0);
|
|
170
|
-
date.setUTCMinutes(date.getUTCMinutes() + 1);
|
|
171
|
-
const maxIterations = 60 * 24 * 366 * 5;
|
|
172
|
-
for (let i = 0; i < maxIterations; i++) {
|
|
173
|
-
if (spec.minute.includes(date.getUTCMinutes()) &&
|
|
174
|
-
spec.hour.includes(date.getUTCHours()) &&
|
|
175
|
-
spec.day.includes(date.getUTCDate()) &&
|
|
176
|
-
spec.month.includes(date.getUTCMonth() + 1) &&
|
|
177
|
-
spec.dow.includes(date.getUTCDay())) {
|
|
178
|
-
return date.getTime();
|
|
179
|
-
}
|
|
180
|
-
date.setUTCMinutes(date.getUTCMinutes() + 1);
|
|
181
|
-
}
|
|
182
|
-
throw new Error("cron search exceeded 5 years without finding a match");
|
|
183
|
-
}
|
|
184
|
-
export function toMs(duration) {
|
|
185
|
-
if (typeof duration === "number")
|
|
186
|
-
return duration;
|
|
187
|
-
const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(duration);
|
|
188
|
-
if (!m)
|
|
189
|
-
throw new Error(`invalid duration "${duration}"`);
|
|
190
|
-
const n = Number(m[1]);
|
|
191
|
-
switch (m[2]) {
|
|
192
|
-
case "ms":
|
|
193
|
-
return n;
|
|
194
|
-
case "s":
|
|
195
|
-
return n * 1_000;
|
|
196
|
-
case "m":
|
|
197
|
-
return n * 60_000;
|
|
198
|
-
case "h":
|
|
199
|
-
return n * 3_600_000;
|
|
200
|
-
case "d":
|
|
201
|
-
return n * 86_400_000;
|
|
202
|
-
case "w":
|
|
203
|
-
return n * 604_800_000;
|
|
204
|
-
default:
|
|
205
|
-
throw new Error(`invalid duration "${duration}"`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
1
|
+
export { computeNextFire, createScheduler, manifestScheduleSources, nextCronFire, parseCron, toMs, } from "@voyantjs/workflows-orchestrator";
|
package/dist/wakeup-store.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wakeup-store.d.ts","sourceRoot":"","sources":["../src/wakeup-store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAA;AAGjE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAA;IACzD,MAAM,EAAE,CACN,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,GAAG;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAC7D,OAAO,CAAC,YAAY,CAAC,CAAA;IAC1B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC3C,IAAI,EAAE,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,KAAK,EAAE,MAAM,CAAA;QACb,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,OAAO,EAAE,MAAM,CAAA;QACf,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1D;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,WAAW,CA4EhF;AAED,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"wakeup-store.d.ts","sourceRoot":"","sources":["../src/wakeup-store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAA;AAGjE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAA;IACzD,MAAM,EAAE,CACN,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,GAAG;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAC7D,OAAO,CAAC,YAAY,CAAC,CAAA;IAC1B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC3C,IAAI,EAAE,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,KAAK,EAAE,MAAM,CAAA;QACb,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,OAAO,EAAE,MAAM,CAAA;QACf,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IAC7B,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1D;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,WAAW,CA4EhF;AAED,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAa/F"}
|
package/dist/wakeup-store.js
CHANGED
|
@@ -41,7 +41,7 @@ export function createFsWakeupStore(opts = {}) {
|
|
|
41
41
|
return wakeups;
|
|
42
42
|
},
|
|
43
43
|
async leaseDue({ owner, now: at = now(), leaseMs, limit = 25 }) {
|
|
44
|
-
const wakeups = await this.list();
|
|
44
|
+
const wakeups = (await this.list()).sort(compareWakeupClaimOrder);
|
|
45
45
|
const leased = [];
|
|
46
46
|
for (const wakeup of wakeups) {
|
|
47
47
|
if (leased.length >= limit)
|
|
@@ -86,10 +86,17 @@ export async function syncWakeupFromRecord(store, record) {
|
|
|
86
86
|
await store.upsert({
|
|
87
87
|
runId: record.id,
|
|
88
88
|
wakeAt,
|
|
89
|
+
priority: record.priority,
|
|
89
90
|
leaseOwner: undefined,
|
|
90
91
|
leaseExpiresAt: undefined,
|
|
91
92
|
});
|
|
92
93
|
}
|
|
94
|
+
function compareWakeupClaimOrder(a, b) {
|
|
95
|
+
const priorityDelta = (b.priority ?? 0) - (a.priority ?? 0);
|
|
96
|
+
if (priorityDelta !== 0)
|
|
97
|
+
return priorityDelta;
|
|
98
|
+
return a.wakeAt - b.wakeAt;
|
|
99
|
+
}
|
|
93
100
|
async function safeReaddir(dir) {
|
|
94
101
|
try {
|
|
95
102
|
const entries = await readdir(dir);
|
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",
|
|
@@ -244,8 +268,9 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
244
268
|
tags: triggerOpts?.tags,
|
|
245
269
|
idempotencyKey: triggerOpts?.idempotencyKey,
|
|
246
270
|
delay: triggerOpts?.delay,
|
|
271
|
+
priority: triggerOpts?.priority,
|
|
247
272
|
},
|
|
248
|
-
|
|
273
|
+
policy,
|
|
249
274
|
)
|
|
250
275
|
// Sync wakeup row so the time-wheel can resume DATETIME-parked runs.
|
|
251
276
|
// No-op if the run completed inline (status !== "waiting").
|
|
@@ -286,7 +311,7 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
286
311
|
continue
|
|
287
312
|
}
|
|
288
313
|
try {
|
|
289
|
-
const record = await
|
|
314
|
+
const record = await triggerRecord(
|
|
290
315
|
{
|
|
291
316
|
workflowId: entry.targetWorkflowId,
|
|
292
317
|
workflowVersion: "v1",
|
|
@@ -301,7 +326,12 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
301
326
|
filterId: entry.filterId,
|
|
302
327
|
},
|
|
303
328
|
},
|
|
304
|
-
|
|
329
|
+
await resolveConcurrencyPolicy(
|
|
330
|
+
entry.targetWorkflowId,
|
|
331
|
+
entry.targetWorkflowId,
|
|
332
|
+
args.environment,
|
|
333
|
+
getManifest,
|
|
334
|
+
),
|
|
305
335
|
)
|
|
306
336
|
await syncWakeupFromRecord(wakeupStore, record)
|
|
307
337
|
matches.push({
|
|
@@ -339,6 +369,72 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
339
369
|
// Idempotent — calling stop() on an already-stopped manager is a
|
|
340
370
|
// no-op.
|
|
341
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
|
+
)
|
|
342
438
|
}
|
|
343
439
|
|
|
344
440
|
// ---- WorkflowAdmin (full; Mode 2 has Postgres-native query support) ----
|
|
@@ -386,10 +482,13 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
|
|
|
386
482
|
// default (architecture doc §21.21). The `compensate` flag is
|
|
387
483
|
// accepted but no-ops in v1.
|
|
388
484
|
void cancelOpts?.compensate
|
|
389
|
-
await orchestratorCancel(
|
|
485
|
+
const out = await orchestratorCancel(
|
|
390
486
|
{ runId, reason: cancelOpts?.reason },
|
|
391
487
|
{ store: runStore, handler, now },
|
|
392
488
|
)
|
|
489
|
+
if (out.ok && isTerminal(out.record.status)) {
|
|
490
|
+
concurrency.releaseRun(out.record)
|
|
491
|
+
}
|
|
393
492
|
},
|
|
394
493
|
|
|
395
494
|
streamRun(runId: string): AsyncIterable<never> {
|
|
@@ -428,6 +527,35 @@ function assertNotShutdown(shuttingDown: boolean): void {
|
|
|
428
527
|
}
|
|
429
528
|
}
|
|
430
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
|
+
|
|
431
559
|
function randomToken(): string {
|
|
432
560
|
return Math.floor(Math.random() * 1_000_000_000)
|
|
433
561
|
.toString(36)
|
package/src/postgres-schema.ts
CHANGED
|
@@ -61,12 +61,13 @@ export const wakeupsTable = pgTable(
|
|
|
61
61
|
{
|
|
62
62
|
runId: text("run_id").primaryKey(),
|
|
63
63
|
wakeAt: bigint("wake_at", { mode: "number" }).notNull(),
|
|
64
|
+
priority: integer("priority").notNull().default(0),
|
|
64
65
|
leaseOwner: text("lease_owner"),
|
|
65
66
|
leaseExpiresAt: bigint("lease_expires_at", { mode: "number" }),
|
|
66
67
|
updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
|
|
67
68
|
},
|
|
68
69
|
(table) => ({
|
|
69
|
-
dueIdx: index("voyant_wakeups_due_idx").on(table.wakeAt),
|
|
70
|
+
dueIdx: index("voyant_wakeups_due_idx").on(table.priority.desc(), table.wakeAt.asc()),
|
|
70
71
|
leaseIdx: index("voyant_wakeups_lease_idx").on(table.leaseExpiresAt),
|
|
71
72
|
}),
|
|
72
73
|
)
|
|
@@ -62,6 +62,7 @@ export function createPostgresWakeupStore(opts: PostgresWakeupStoreOptions): Wak
|
|
|
62
62
|
wake_at: number
|
|
63
63
|
lease_owner: string | null
|
|
64
64
|
lease_expires_at: number | null
|
|
65
|
+
priority: number
|
|
65
66
|
updated_at: number
|
|
66
67
|
}>(sql`
|
|
67
68
|
WITH due AS (
|
|
@@ -73,7 +74,7 @@ export function createPostgresWakeupStore(opts: PostgresWakeupStoreOptions): Wak
|
|
|
73
74
|
OR lease_expires_at <= ${at}
|
|
74
75
|
OR lease_owner = ${owner}
|
|
75
76
|
)
|
|
76
|
-
ORDER BY wake_at ASC
|
|
77
|
+
ORDER BY priority DESC, wake_at ASC
|
|
77
78
|
LIMIT ${limit}
|
|
78
79
|
FOR UPDATE SKIP LOCKED
|
|
79
80
|
)
|
|
@@ -83,18 +84,25 @@ export function createPostgresWakeupStore(opts: PostgresWakeupStoreOptions): Wak
|
|
|
83
84
|
updated_at = ${at}
|
|
84
85
|
FROM due
|
|
85
86
|
WHERE wakeups.run_id = due.run_id
|
|
86
|
-
RETURNING wakeups.run_id, wakeups.wake_at, wakeups.lease_owner, wakeups.lease_expires_at, wakeups.updated_at
|
|
87
|
+
RETURNING wakeups.run_id, wakeups.wake_at, wakeups.priority, wakeups.lease_owner, wakeups.lease_expires_at, wakeups.updated_at
|
|
87
88
|
`)
|
|
88
89
|
const rows = result.rows as Array<{
|
|
89
90
|
run_id: string
|
|
90
91
|
wake_at: number | string
|
|
92
|
+
priority: number | string
|
|
91
93
|
lease_owner: string | null
|
|
92
94
|
lease_expires_at: number | string | null
|
|
93
95
|
updated_at: number | string
|
|
94
96
|
}>
|
|
97
|
+
rows.sort((a, b) => {
|
|
98
|
+
const priorityDelta = toNumber(b.priority) - toNumber(a.priority)
|
|
99
|
+
if (priorityDelta !== 0) return priorityDelta
|
|
100
|
+
return toNumber(a.wake_at) - toNumber(b.wake_at)
|
|
101
|
+
})
|
|
95
102
|
return rows.map((row) => ({
|
|
96
103
|
runId: row.run_id,
|
|
97
104
|
wakeAt: toNumber(row.wake_at),
|
|
105
|
+
priority: toNumber(row.priority),
|
|
98
106
|
leaseOwner: row.lease_owner ?? undefined,
|
|
99
107
|
leaseExpiresAt: row.lease_expires_at === null ? undefined : toNumber(row.lease_expires_at),
|
|
100
108
|
updatedAt: toNumber(row.updated_at),
|
|
@@ -125,6 +133,7 @@ export function wakeupToRow(
|
|
|
125
133
|
return {
|
|
126
134
|
runId: record.runId,
|
|
127
135
|
wakeAt: record.wakeAt,
|
|
136
|
+
priority: record.priority ?? 0,
|
|
128
137
|
leaseOwner: record.leaseOwner ?? null,
|
|
129
138
|
leaseExpiresAt: record.leaseExpiresAt ?? null,
|
|
130
139
|
updatedAt,
|
|
@@ -135,6 +144,7 @@ export function rowToWakeupRecord(row: typeof wakeupsTable.$inferSelect): Wakeup
|
|
|
135
144
|
return {
|
|
136
145
|
runId: row.runId,
|
|
137
146
|
wakeAt: row.wakeAt,
|
|
147
|
+
priority: row.priority,
|
|
138
148
|
leaseOwner: row.leaseOwner ?? undefined,
|
|
139
149
|
leaseExpiresAt: row.leaseExpiresAt ?? undefined,
|
|
140
150
|
updatedAt: row.updatedAt,
|
package/src/scheduler.ts
CHANGED
|
@@ -1,250 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
setInterval?: typeof setInterval
|
|
15
|
-
clearInterval?: typeof clearInterval
|
|
16
|
-
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SchedulerHandle {
|
|
20
|
-
start: () => void
|
|
21
|
-
stop: () => void
|
|
22
|
-
tick: () => Promise<void>
|
|
23
|
-
nextFirings: () => { workflowId: string; name?: string; nextAt: number; done: boolean }[]
|
|
24
|
-
sourceCount: () => number
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface SourceState {
|
|
28
|
-
source: ScheduleSource
|
|
29
|
-
nextAt: number
|
|
30
|
-
done: boolean
|
|
31
|
-
inFlight: boolean
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function createScheduler(deps: SchedulerDeps): SchedulerHandle {
|
|
35
|
-
const now = deps.now ?? (() => Date.now())
|
|
36
|
-
const tickMs = deps.tickMs ?? 1_000
|
|
37
|
-
const setInt = deps.setInterval ?? setInterval
|
|
38
|
-
const clearInt = deps.clearInterval ?? clearInterval
|
|
39
|
-
const env = deps.environment ?? "development"
|
|
40
|
-
const log = deps.logger ?? (() => {})
|
|
41
|
-
|
|
42
|
-
const states: SourceState[] = []
|
|
43
|
-
for (const source of deps.sources) {
|
|
44
|
-
if (source.decl.enabled === false) continue
|
|
45
|
-
if (source.decl.environments && !source.decl.environments.includes(env)) continue
|
|
46
|
-
let firstAt: number
|
|
47
|
-
try {
|
|
48
|
-
firstAt = computeNextFire(source.decl, now())
|
|
49
|
-
} catch (err) {
|
|
50
|
-
log("warn", `scheduler: skipping source for workflow "${source.workflowId}": ${String(err)}`)
|
|
51
|
-
continue
|
|
52
|
-
}
|
|
53
|
-
states.push({ source, nextAt: firstAt, done: false, inFlight: false })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
let timer: ReturnType<typeof setInterval> | undefined
|
|
57
|
-
|
|
58
|
-
const advanceAfterFire = (state: SourceState, firedAt: number): void => {
|
|
59
|
-
if ("at" in state.source.decl) {
|
|
60
|
-
state.done = true
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
state.nextAt = computeNextFire(state.source.decl, firedAt)
|
|
65
|
-
} catch (err) {
|
|
66
|
-
log(
|
|
67
|
-
"error",
|
|
68
|
-
`scheduler: cannot compute next fire for "${state.source.workflowId}": ${String(err)}`,
|
|
69
|
-
)
|
|
70
|
-
state.done = true
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const doTick = async (): Promise<void> => {
|
|
75
|
-
const t = now()
|
|
76
|
-
const ready = states.filter((state) => !state.done && state.nextAt <= t)
|
|
77
|
-
for (const state of ready) {
|
|
78
|
-
const overlap = state.source.decl.overlap ?? "skip"
|
|
79
|
-
if (state.inFlight && overlap === "skip") continue
|
|
80
|
-
let input: unknown
|
|
81
|
-
try {
|
|
82
|
-
input = await resolveInput(state.source.decl.input)
|
|
83
|
-
} catch (err) {
|
|
84
|
-
log(
|
|
85
|
-
"error",
|
|
86
|
-
`scheduler: failed to resolve input for "${state.source.workflowId}": ${String(err)}`,
|
|
87
|
-
)
|
|
88
|
-
advanceAfterFire(state, t)
|
|
89
|
-
continue
|
|
90
|
-
}
|
|
91
|
-
state.inFlight = true
|
|
92
|
-
const firePromise = (async () => {
|
|
93
|
-
try {
|
|
94
|
-
await deps.onFire({
|
|
95
|
-
workflowId: state.source.workflowId,
|
|
96
|
-
input,
|
|
97
|
-
scheduleName: state.source.decl.name,
|
|
98
|
-
})
|
|
99
|
-
} catch (err) {
|
|
100
|
-
log("error", `scheduler: onFire threw for "${state.source.workflowId}": ${String(err)}`)
|
|
101
|
-
} finally {
|
|
102
|
-
state.inFlight = false
|
|
103
|
-
}
|
|
104
|
-
})()
|
|
105
|
-
advanceAfterFire(state, t)
|
|
106
|
-
if (overlap === "skip") await firePromise
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
start() {
|
|
112
|
-
if (timer) return
|
|
113
|
-
timer = setInt(() => {
|
|
114
|
-
doTick().catch(() => {})
|
|
115
|
-
}, tickMs)
|
|
116
|
-
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
117
|
-
},
|
|
118
|
-
stop() {
|
|
119
|
-
if (!timer) return
|
|
120
|
-
clearInt(timer)
|
|
121
|
-
timer = undefined
|
|
122
|
-
},
|
|
123
|
-
tick: doTick,
|
|
124
|
-
nextFirings() {
|
|
125
|
-
return states.map((state) => ({
|
|
126
|
-
workflowId: state.source.workflowId,
|
|
127
|
-
name: state.source.decl.name,
|
|
128
|
-
nextAt: state.nextAt,
|
|
129
|
-
done: state.done,
|
|
130
|
-
}))
|
|
131
|
-
},
|
|
132
|
-
sourceCount() {
|
|
133
|
-
return states.length
|
|
134
|
-
},
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function resolveInput(
|
|
139
|
-
input: unknown | (() => unknown | Promise<unknown>) | undefined,
|
|
140
|
-
): Promise<unknown> {
|
|
141
|
-
if (typeof input === "function") {
|
|
142
|
-
return await (input as () => unknown | Promise<unknown>)()
|
|
143
|
-
}
|
|
144
|
-
return input
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function computeNextFire(decl: ScheduleDeclaration, fromMs: number): number {
|
|
148
|
-
if ("cron" in decl) return nextCronFire(parseCron(decl.cron), fromMs)
|
|
149
|
-
if ("every" in decl) return fromMs + toMs(decl.every)
|
|
150
|
-
if ("at" in decl) {
|
|
151
|
-
const at = typeof decl.at === "string" ? Date.parse(decl.at) : decl.at.getTime()
|
|
152
|
-
if (!Number.isFinite(at)) throw new Error(`invalid "at" value: ${String(decl.at)}`)
|
|
153
|
-
return at < fromMs ? Number.POSITIVE_INFINITY : at
|
|
154
|
-
}
|
|
155
|
-
throw new Error(`schedule declaration missing one of cron/every/at`)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export interface CronSpec {
|
|
159
|
-
minute: number[]
|
|
160
|
-
hour: number[]
|
|
161
|
-
day: number[]
|
|
162
|
-
month: number[]
|
|
163
|
-
dow: number[]
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function parseCron(expr: string): CronSpec {
|
|
167
|
-
const parts = expr.trim().split(/\s+/)
|
|
168
|
-
if (parts.length !== 5) {
|
|
169
|
-
throw new Error(`invalid cron "${expr}" — expected 5 fields (minute hour day month dow)`)
|
|
170
|
-
}
|
|
171
|
-
return {
|
|
172
|
-
minute: parseField(parts[0]!, 0, 59, "minute"),
|
|
173
|
-
hour: parseField(parts[1]!, 0, 23, "hour"),
|
|
174
|
-
day: parseField(parts[2]!, 1, 31, "day"),
|
|
175
|
-
month: parseField(parts[3]!, 1, 12, "month"),
|
|
176
|
-
dow: parseField(parts[4]!, 0, 6, "dow"),
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function parseField(f: string, min: number, max: number, label: string): number[] {
|
|
181
|
-
const out = new Set<number>()
|
|
182
|
-
for (const part of f.split(",")) {
|
|
183
|
-
const stepMatch = /^(.+)\/(\d+)$/.exec(part)
|
|
184
|
-
const body = stepMatch ? stepMatch[1]! : part
|
|
185
|
-
const step = stepMatch ? Number(stepMatch[2]) : 1
|
|
186
|
-
if (!(step >= 1)) throw new Error(`cron ${label} step must be >=1 in "${f}"`)
|
|
187
|
-
let lo: number
|
|
188
|
-
let hi: number
|
|
189
|
-
if (body === "*") {
|
|
190
|
-
lo = min
|
|
191
|
-
hi = max
|
|
192
|
-
} else if (body.includes("-")) {
|
|
193
|
-
const [a, b] = body.split("-")
|
|
194
|
-
lo = Number(a)
|
|
195
|
-
hi = Number(b)
|
|
196
|
-
} else {
|
|
197
|
-
lo = Number(body)
|
|
198
|
-
hi = lo
|
|
199
|
-
}
|
|
200
|
-
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo < min || hi > max || lo > hi) {
|
|
201
|
-
throw new Error(`cron ${label} out of range [${min}..${max}] in "${f}"`)
|
|
202
|
-
}
|
|
203
|
-
for (let i = lo; i <= hi; i += step) out.add(i)
|
|
204
|
-
}
|
|
205
|
-
return [...out].sort((a, b) => a - b)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export function nextCronFire(spec: CronSpec, fromMs: number): number {
|
|
209
|
-
const date = new Date(fromMs)
|
|
210
|
-
date.setUTCSeconds(0, 0)
|
|
211
|
-
date.setUTCMinutes(date.getUTCMinutes() + 1)
|
|
212
|
-
|
|
213
|
-
const maxIterations = 60 * 24 * 366 * 5
|
|
214
|
-
for (let i = 0; i < maxIterations; i++) {
|
|
215
|
-
if (
|
|
216
|
-
spec.minute.includes(date.getUTCMinutes()) &&
|
|
217
|
-
spec.hour.includes(date.getUTCHours()) &&
|
|
218
|
-
spec.day.includes(date.getUTCDate()) &&
|
|
219
|
-
spec.month.includes(date.getUTCMonth() + 1) &&
|
|
220
|
-
spec.dow.includes(date.getUTCDay())
|
|
221
|
-
) {
|
|
222
|
-
return date.getTime()
|
|
223
|
-
}
|
|
224
|
-
date.setUTCMinutes(date.getUTCMinutes() + 1)
|
|
225
|
-
}
|
|
226
|
-
throw new Error("cron search exceeded 5 years without finding a match")
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function toMs(duration: Duration): number {
|
|
230
|
-
if (typeof duration === "number") return duration
|
|
231
|
-
const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(duration)
|
|
232
|
-
if (!m) throw new Error(`invalid duration "${duration}"`)
|
|
233
|
-
const n = Number(m[1])
|
|
234
|
-
switch (m[2]) {
|
|
235
|
-
case "ms":
|
|
236
|
-
return n
|
|
237
|
-
case "s":
|
|
238
|
-
return n * 1_000
|
|
239
|
-
case "m":
|
|
240
|
-
return n * 60_000
|
|
241
|
-
case "h":
|
|
242
|
-
return n * 3_600_000
|
|
243
|
-
case "d":
|
|
244
|
-
return n * 86_400_000
|
|
245
|
-
case "w":
|
|
246
|
-
return n * 604_800_000
|
|
247
|
-
default:
|
|
248
|
-
throw new Error(`invalid duration "${duration}"`)
|
|
249
|
-
}
|
|
250
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
type CronSpec,
|
|
3
|
+
computeNextFire,
|
|
4
|
+
createScheduler,
|
|
5
|
+
manifestScheduleSources,
|
|
6
|
+
nextCronFire,
|
|
7
|
+
parseCron,
|
|
8
|
+
type SchedulableDeclaration,
|
|
9
|
+
type SchedulerDeps,
|
|
10
|
+
type SchedulerHandle,
|
|
11
|
+
type ScheduleSource,
|
|
12
|
+
toMs,
|
|
13
|
+
} from "@voyantjs/workflows-orchestrator"
|
package/src/wakeup-store.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { findEarliestWakeAt } from "./sleep-alarm-manager.js"
|
|
|
6
6
|
export interface WakeupRecord {
|
|
7
7
|
runId: string
|
|
8
8
|
wakeAt: number
|
|
9
|
+
priority?: number
|
|
9
10
|
leaseOwner?: string
|
|
10
11
|
leaseExpiresAt?: number
|
|
11
12
|
updatedAt: number
|
|
@@ -74,7 +75,7 @@ export function createFsWakeupStore(opts: FsWakeupStoreOptions = {}): WakeupStor
|
|
|
74
75
|
},
|
|
75
76
|
|
|
76
77
|
async leaseDue({ owner, now: at = now(), leaseMs, limit = 25 }) {
|
|
77
|
-
const wakeups = await this.list()
|
|
78
|
+
const wakeups = (await this.list()).sort(compareWakeupClaimOrder)
|
|
78
79
|
const leased: WakeupRecord[] = []
|
|
79
80
|
for (const wakeup of wakeups) {
|
|
80
81
|
if (leased.length >= limit) break
|
|
@@ -119,11 +120,18 @@ export async function syncWakeupFromRecord(store: WakeupStore, record: RunRecord
|
|
|
119
120
|
await store.upsert({
|
|
120
121
|
runId: record.id,
|
|
121
122
|
wakeAt,
|
|
123
|
+
priority: record.priority,
|
|
122
124
|
leaseOwner: undefined,
|
|
123
125
|
leaseExpiresAt: undefined,
|
|
124
126
|
})
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
function compareWakeupClaimOrder(a: WakeupRecord, b: WakeupRecord): number {
|
|
130
|
+
const priorityDelta = (b.priority ?? 0) - (a.priority ?? 0)
|
|
131
|
+
if (priorityDelta !== 0) return priorityDelta
|
|
132
|
+
return a.wakeAt - b.wakeAt
|
|
133
|
+
}
|
|
134
|
+
|
|
127
135
|
async function safeReaddir(dir: string): Promise<string[]> {
|
|
128
136
|
try {
|
|
129
137
|
const entries = await readdir(dir)
|