@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/README.md +19 -1
- package/dist/client.d.ts +113 -25
- package/dist/client.js +105 -66
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/worker.d.ts +37 -11
- package/dist/worker.js +77 -58
- package/package.json +8 -5
- package/src/client.ts +188 -103
- package/src/index.ts +8 -2
- package/src/worker.ts +108 -62
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:
|
|
3
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
/**
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
84
|
+
private workers: NanoJobWorker[] = [];
|
|
64
85
|
|
|
65
86
|
constructor(opts: WorkerOptions) {
|
|
66
|
-
this.client =
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
}
|