@nanobpm/workflow 0.4.0 → 0.6.0

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/src/worker.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The Worker runtime — hosts one or more workflows against a gateway and drives
2
- // them: long-polls each derived job type, dispatches jobs to handlers, completes
3
- // them. It generalises the two surfaces:
2
+ // them: it registers one nano-sdk job worker per derived job type, dispatches
3
+ // each activated job to its handler, and completes it. It generalises the two
4
+ // surfaces:
4
5
  //
5
6
  // - a declarative flow contributes one job type per `run` step, dispatched to
6
7
  // the user handler (external `task` steps are intentionally NOT hosted here —
@@ -8,11 +9,13 @@
8
9
  // - an imperative workflow contributes its single orchestrator job type,
9
10
  // dispatched to the replay engine (which advances the journal one step).
10
11
  //
11
- // It is resilient to the gateway disappearing (each poll loop backs off and
12
- // reconnects on its own), so it survives an engine crash/restart — the property
13
- // the ADR 0044 spike proved.
12
+ // Transport (activation, completion, failure, long-poll, backoff/reconnect) is
13
+ // the nano-sdk job worker (ADR 0055), reached through `WorkflowClient.sdk`. The
14
+ // nano-sdk worker reschedules its poll on an activation error, so the runtime
15
+ // survives an engine crash/restart — the property the ADR 0044 spike proved.
14
16
 
15
17
  import { WorkflowClient } from "./client.js";
18
+ import type { ActivatedJob, NanoJobWorker } from "./client.js";
16
19
  import { replayOnce } from "./imperative.js";
17
20
  import { walkNodes } from "./declarative.js";
18
21
  import type { Job, JsonObject, Workflow } from "./types.js";
@@ -28,46 +31,66 @@ export interface ActivityEvent {
28
31
  step?: string;
29
32
  }
30
33
 
31
- export interface WorkerOptions {
32
- /** Provide a baseUrl (a client is created) or an existing client. */
33
- baseUrl?: string;
34
- client?: WorkflowClient;
34
+ /** Options common to both ways of building a `Worker`. */
35
+ interface WorkerCommon {
35
36
  workflows: Workflow[];
36
37
  /** Worker name reported to the gateway. Default "nanobpm-workflow". */
37
38
  name?: string;
39
+ /** Transport for the worker's own client (used only when `client` is not
40
+ * supplied): "auto" | "falcon" | "rest". Defaults to "rest" — job serving is
41
+ * resilience-critical, and REST long-poll reconnects with backoff after an
42
+ * engine restart (the ADR 0044 property), whereas the Falcon push transport
43
+ * does not recover a mid-stream disconnect. Instance creation, by contrast,
44
+ * defaults to Falcon on its own `WorkflowClient` for throughput. */
45
+ transport?: "auto" | "falcon" | "rest";
38
46
  /** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
39
47
  pollTimeoutMs?: number;
40
48
  /** Job lock timeout, ms. Default 30000. */
41
49
  jobTimeoutMs?: number;
42
- /** Backoff after a transport error, ms. Default 500. */
43
- backoffMs?: number;
50
+ /** Max jobs handled in parallel per derived job type. Default 1. */
51
+ maxParallelJobs?: number;
44
52
  onActivity?: (e: ActivityEvent) => void | Promise<void>;
45
53
  onError?: (err: Error, context: { type: string }) => void;
46
54
  }
47
55
 
48
- type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
56
+ /** Build a `Worker` from **either** a `baseUrl` (a `WorkflowClient` is created)
57
+ * **or** an existing `client`. The union makes TypeScript enforce that exactly
58
+ * one is supplied, matching the constructor's runtime requirement. */
59
+ export type WorkerOptions =
60
+ | (WorkerCommon & {
61
+ /** Base URL of the nanobpmn gateway; a `WorkflowClient` is created for you. */
62
+ baseUrl: string;
63
+ client?: never;
64
+ })
65
+ | (WorkerCommon & {
66
+ /** An existing `WorkflowClient` to serve jobs through. */
67
+ client: WorkflowClient;
68
+ baseUrl?: never;
69
+ });
49
70
 
50
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
71
+ type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
51
72
 
52
73
  export class Worker {
53
74
  private readonly client: WorkflowClient;
54
75
  private readonly name: string;
55
76
  private readonly pollTimeoutMs: number;
56
77
  private readonly jobTimeoutMs: number;
57
- private readonly backoffMs: number;
78
+ private readonly maxParallelJobs: number;
58
79
  private readonly onActivity?: WorkerOptions["onActivity"];
59
80
  private readonly onError?: WorkerOptions["onError"];
60
81
  /** job type → { workflowId, handle } */
61
82
  private readonly routes = new Map<string, { workflowId: string; handle: Handler }>();
62
83
  private running = false;
63
- private loops: Promise<void>[] = [];
84
+ private workers: NanoJobWorker[] = [];
64
85
 
65
86
  constructor(opts: WorkerOptions) {
66
- this.client = opts.client ?? new WorkflowClient({ baseUrl: requireBaseUrl(opts) });
87
+ this.client =
88
+ opts.client ??
89
+ new WorkflowClient({ baseUrl: requireBaseUrl(opts), transport: opts.transport ?? "rest" });
67
90
  this.name = opts.name ?? "nanobpm-workflow";
68
91
  this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
69
92
  this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
70
- this.backoffMs = opts.backoffMs ?? 500;
93
+ this.maxParallelJobs = opts.maxParallelJobs ?? 1;
71
94
  this.onActivity = opts.onActivity;
72
95
  this.onError = opts.onError;
73
96
  for (const wf of opts.workflows) this.register(wf);
@@ -152,61 +175,84 @@ export class Worker {
152
175
  return [...this.routes.keys()];
153
176
  }
154
177
 
155
- /** Begin polling. Resolves once the loops are running (they run until stop()). */
178
+ /** Begin serving. Creates one nano-sdk job worker per derived job type with
179
+ * `autoStart: false`, then starts each explicitly, so the poll lifecycle is
180
+ * deterministic and owned here rather than left to the SDK's auto-start
181
+ * default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
182
+ * before `start()` is called. */
156
183
  start(): void {
157
184
  if (this.running) return;
158
185
  this.running = true;
159
- this.loops = [...this.routes.entries()].map(([type, route]) => this.pollLoop(type, route));
186
+ this.workers = [...this.routes.entries()].map(([type, route]) => {
187
+ const worker = this.client.sdk.createJobWorker({
188
+ jobType: type,
189
+ workerName: this.name,
190
+ maxParallelJobs: this.maxParallelJobs,
191
+ jobTimeoutMs: this.jobTimeoutMs,
192
+ pollTimeoutMs: this.pollTimeoutMs,
193
+ autoStart: false,
194
+ jobHandler: (job) => this.handleJob(type, route, job),
195
+ });
196
+ worker.start();
197
+ return worker;
198
+ });
160
199
  }
161
200
 
162
- /** Stop polling and wait for in-flight loops to unwind (≤ pollTimeoutMs). */
201
+ /** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
202
+ * per worker: the Falcon worker's `stop` throws if it never bound a transport
203
+ * (detection still pending), which must not fail the whole shutdown. */
163
204
  async stop(): Promise<void> {
164
205
  this.running = false;
165
- await Promise.all(this.loops);
166
- this.loops = [];
206
+ await Promise.all(
207
+ this.workers.map(async (w) => {
208
+ try {
209
+ await (w.stopGracefully
210
+ ? w.stopGracefully({ waitUpToMs: this.pollTimeoutMs })
211
+ : w.stop());
212
+ } catch {
213
+ /* worker never fully started (transport unbound); nothing to drain */
214
+ }
215
+ }),
216
+ );
217
+ this.workers = [];
167
218
  }
168
219
 
169
- private async pollLoop(type: string, route: { workflowId: string; handle: Handler }): Promise<void> {
170
- while (this.running) {
171
- let jobs: Job[] = [];
220
+ /** Dispatch one activated job to its route handler and acknowledge it. Returns
221
+ * the nano-sdk action receipt (its `complete`/`fail` result). */
222
+ private async handleJob(
223
+ type: string,
224
+ route: { workflowId: string; handle: Handler },
225
+ job: ActivatedJob,
226
+ ): Promise<unknown> {
227
+ try {
228
+ const { variables, step } = await route.handle(job);
229
+ const receipt = await job.complete(variables);
230
+ // Job is now completed; the observer hook is isolated (emitActivity
231
+ // swallows its own errors) so it can never fall into the failure path.
232
+ await this.emitActivity({
233
+ workflowId: route.workflowId,
234
+ type,
235
+ jobKey: job.jobKey,
236
+ elementId: job.elementId,
237
+ step,
238
+ });
239
+ return receipt;
240
+ } catch (e) {
241
+ // A handler or completion failed. Report; the engine will redeliver the
242
+ // job after its lock times out (at-least-once → handlers must be
243
+ // idempotent). Best-effort surface it as an incident-worthy failure.
244
+ // Normalise non-Error throws (strings, objects) so emitError always gets
245
+ // an Error and errorMessage is never undefined in the failure report.
246
+ const err = e instanceof Error ? e : new Error(String(e));
247
+ this.emitError(err, type);
172
248
  try {
173
- jobs = await this.client.activateJobs(type, {
174
- worker: this.name,
175
- maxJobsToActivate: 1,
176
- timeout: this.jobTimeoutMs,
177
- requestTimeout: this.pollTimeoutMs,
178
- });
179
- } catch (e) {
180
- // Transport/gateway error (e.g. engine restarting): back off, reconnect.
181
- this.emitError(e as Error, type);
182
- await sleep(this.backoffMs);
183
- continue;
184
- }
185
- for (const job of jobs) {
186
- if (!this.running) break;
187
- try {
188
- const { variables, step } = await route.handle(job);
189
- await this.client.completeJob(job.jobKey, variables);
190
- // Job is now completed; the observer hook is isolated (emitActivity
191
- // swallows its own errors) so it can never fall into the failure path.
192
- await this.emitActivity({
193
- workflowId: route.workflowId,
194
- type,
195
- jobKey: job.jobKey,
196
- elementId: job.elementId,
197
- step,
198
- });
199
- } catch (e) {
200
- // A handler or completion failed. Report; the engine will redeliver the
201
- // job after its lock times out (at-least-once → handlers must be
202
- // idempotent). Best-effort surface it as an incident-worthy failure.
203
- this.emitError(e as Error, type);
204
- try {
205
- await this.client.failJob(job.jobKey, (e as Error).message, 0);
206
- } catch {
207
- /* engine may be down; the lock will expire and redeliver */
208
- }
209
- }
249
+ return await job.fail({ errorMessage: err.message, retries: 0 });
250
+ } catch {
251
+ // Even reporting the failure failed (e.g. the gateway is unreachable).
252
+ // Awaited + swallowed so a rejected `fail` can't escape as an unhandled
253
+ // rejection out of the SDK worker's handler; the engine redelivers the
254
+ // job once its lock times out regardless.
255
+ return undefined;
210
256
  }
211
257
  }
212
258
  }