@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.
@@ -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,EAGL,KAAK,SAAS,EAEd,KAAK,WAAW,EACjB,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;CAC3B;AAUD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,2BAA2B,GAAG,aAAa,CA8R3F"}
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 record = await orchestratorTrigger({
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
- }, { store: runStore, handler, now });
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 orchestratorTrigger({
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
- }, { store: runStore, handler, now });
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaxB,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAelC,CAAA"}
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"}
@@ -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
  /**
@@ -12,6 +12,7 @@ export declare function wakeupToRow(record: Omit<WakeupRecord, "updatedAt"> & {
12
12
  }, updatedAt: number): {
13
13
  runId: string;
14
14
  wakeAt: number;
15
+ priority: number;
15
16
  leaseOwner: string | null;
16
17
  leaseExpiresAt: number | null;
17
18
  updatedAt: number;
@@ -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,CA0GvF;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,EAC/D,SAAS,EAAE,MAAM;;;;;;EASlB;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,YAAY,CAAC,YAAY,GAAG,YAAY,CAQrF"}
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,
@@ -1,44 +1,2 @@
1
- import type { Duration, EnvironmentName, ScheduleDeclaration } from "@voyantjs/workflows";
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
@@ -1 +1 @@
1
- {"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAEzF,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,mBAAmB,CAAA;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,SAAS,cAAc,EAAE,CAAA;IAClC,MAAM,EAAE,CAAC,IAAI,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9F,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,OAAO,WAAW,CAAA;IAChC,aAAa,CAAC,EAAE,OAAO,aAAa,CAAA;IACpC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACzB,WAAW,EAAE,MAAM;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;IACzF,WAAW,EAAE,MAAM,MAAM,CAAA;CAC1B;AASD,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,CAsGpE;AAWD,wBAAgB,eAAe,CAAC,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CASjF;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,GAAG,EAAE,MAAM,EAAE,CAAA;CACd;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAYhD;AA8BD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAmBnE;AAED,wBAAgB,IAAI,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAqB/C"}
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 function createScheduler(deps) {
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";
@@ -2,6 +2,7 @@ import type { RunRecord } from "@voyantjs/workflows-orchestrator";
2
2
  export interface WakeupRecord {
3
3
  runId: string;
4
4
  wakeAt: number;
5
+ priority?: number;
5
6
  leaseOwner?: string;
6
7
  leaseExpiresAt?: number;
7
8
  updatedAt: number;
@@ -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,CAY/F"}
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"}
@@ -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);
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "voyant_wakeups" ADD COLUMN "priority" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ DROP INDEX "voyant_wakeups_due_idx";--> statement-breakpoint
3
+ CREATE INDEX "voyant_wakeups_due_idx" ON "voyant_wakeups" USING btree ("priority" DESC,"wake_at" ASC);
@@ -36,6 +36,13 @@
36
36
  "when": 1779000001000,
37
37
  "tag": "0004_workflow_manifests",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1779000002000,
44
+ "tag": "0005_wakeup_priority",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/workflows-orchestrator-node",
3
- "version": "0.30.1",
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.1",
33
- "@voyantjs/workflows": "0.30.1"
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 orchestratorTrigger(
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
- { store: runStore, handler, now },
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 orchestratorTrigger(
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
- { store: runStore, handler, now },
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)
@@ -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
- import type { Duration, EnvironmentName, ScheduleDeclaration } from "@voyantjs/workflows"
2
-
3
- export interface ScheduleSource {
4
- workflowId: string
5
- decl: ScheduleDeclaration
6
- }
7
-
8
- export interface SchedulerDeps {
9
- sources: readonly ScheduleSource[]
10
- onFire: (args: { workflowId: string; input: unknown; scheduleName?: string }) => Promise<void>
11
- now?: () => number
12
- environment?: EnvironmentName
13
- tickMs?: number
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"
@@ -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)