@voyantjs/workflows-orchestrator-node 0.30.3 → 0.30.5

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