@orkestrel/worker 0.0.9 → 0.0.11
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 +6 -4
- package/dist/src/core/index.cjs +11 -11
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +36 -35
- package/dist/src/core/index.d.ts +36 -35
- package/dist/src/core/index.js +11 -11
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +238 -216
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +131 -81
- package/dist/src/server/index.d.ts +131 -81
- package/dist/src/server/index.js +237 -215
- package/dist/src/server/index.js.map +1 -1
- package/package.json +21 -18
package/dist/src/server/index.js
CHANGED
|
@@ -3,9 +3,140 @@ import { Worker, parentPort } from "node:worker_threads";
|
|
|
3
3
|
import { createJSONDriver } from "@orkestrel/database/server";
|
|
4
4
|
import { createDatabaseQueueStore } from "@orkestrel/queue";
|
|
5
5
|
import { createWorker } from "../core/index.js";
|
|
6
|
+
//#region src/server/helpers.ts
|
|
7
|
+
/**
|
|
8
|
+
* Narrows an inbound `message` to a {@link Reply} for a given job `id` — no assertion.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* A total predicate: a record whose `id` matches and whose `ok` discriminant is well-formed.
|
|
12
|
+
* Anything else is rejected so a dispatch listener can ignore foreign or malformed messages.
|
|
13
|
+
* It correlates against the `id` argument rather than narrowing one value alone, so it is a
|
|
14
|
+
* correlated predicate rather than a `Guard<Reply>` and is not accepted where a `Guard` is.
|
|
15
|
+
*
|
|
16
|
+
* @param value - The inbound message to narrow
|
|
17
|
+
* @param id - The job id a matching reply must carry
|
|
18
|
+
* @returns True if the value is this job's well-formed reply; false otherwise
|
|
19
|
+
*/
|
|
20
|
+
function isReply(value, id) {
|
|
21
|
+
const outcome = attempt(() => {
|
|
22
|
+
if (!isRecord(value)) return false;
|
|
23
|
+
if (value.id !== id) return false;
|
|
24
|
+
if (value.ok === true) return "value" in value;
|
|
25
|
+
return value.ok === false && typeof value.error === "string";
|
|
26
|
+
});
|
|
27
|
+
return outcome.success && outcome.value;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/server/handlers.ts
|
|
31
|
+
/**
|
|
32
|
+
* Registers a worker-thread handler — the worker-side half of {@link createNodeWorker}.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Must be the spawned thread's module entry. It listens on the parent port for the
|
|
36
|
+
* run/abort protocol: a `run` message narrows its `input` through `options.input` (an
|
|
37
|
+
* invalid payload replies with an error envelope, never running the handler), then runs
|
|
38
|
+
* `options.handler(input, { id: job, signal })` and replies `{ id, ok: true, value }` on success or
|
|
39
|
+
* `{ id, ok: false, error }` on throw. Input-guard throws use the same failure envelope. If a
|
|
40
|
+
* success value cannot be cloned, the post is retried as a clone-safe failure; if that post also
|
|
41
|
+
* fails, the parent port closes so the main side observes thread exit instead of waiting forever.
|
|
42
|
+
* The run envelope's `id` is fresh per dispatch and keys controllers, aborts, and replies;
|
|
43
|
+
* its `job` is the stable Queue idempotency key exposed as `context.id` across retries
|
|
44
|
+
* and restore. That job id identifies work, not a caller, and is not authentication or
|
|
45
|
+
* authorization evidence. Each attempt has its own `AbortController`, so an `abort`
|
|
46
|
+
* message for the correlation id fires the handler's `signal` (cooperative — the main
|
|
47
|
+
* side ALSO terminates the thread, so a handler that ignores its signal is still stopped).
|
|
48
|
+
* Every inbound message is narrowed with the inlined guards — no `as`. On the main thread
|
|
49
|
+
* (`parentPort === null`) it is a no-op.
|
|
50
|
+
*
|
|
51
|
+
* @typeParam TInput - The work payload (inferred from `options.input`)
|
|
52
|
+
* @typeParam TResult - The value the handler resolves (the reply payload)
|
|
53
|
+
* @param options - The `input` guard and the `handler` (see {@link ServeWorkerOptions})
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* // double.ts — a worker script
|
|
58
|
+
* import { serveWorker } from '@orkestrel/worker/server'
|
|
59
|
+
*
|
|
60
|
+
* serveWorker<number, number>({
|
|
61
|
+
* input: (value): value is number => typeof value === 'number',
|
|
62
|
+
* handler: (value) => value * 2,
|
|
63
|
+
* })
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
function serveWorker(options) {
|
|
67
|
+
const port = parentPort;
|
|
68
|
+
if (port === null) return;
|
|
69
|
+
const input = options.input;
|
|
70
|
+
const handler = options.handler;
|
|
71
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
72
|
+
port.on("message", (raw) => {
|
|
73
|
+
let command;
|
|
74
|
+
let correlation;
|
|
75
|
+
let job;
|
|
76
|
+
let payload;
|
|
77
|
+
let carried = false;
|
|
78
|
+
try {
|
|
79
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
80
|
+
if ("command" in raw) command = raw.command;
|
|
81
|
+
if ("id" in raw) correlation = raw.id;
|
|
82
|
+
if ("job" in raw) job = raw.job;
|
|
83
|
+
if ("input" in raw) {
|
|
84
|
+
payload = raw.input;
|
|
85
|
+
carried = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (typeof correlation !== "string") return;
|
|
92
|
+
const id = correlation;
|
|
93
|
+
if (command === "abort") {
|
|
94
|
+
controllers.get(id)?.abort();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (command !== "run" || typeof job !== "string" || !carried) return;
|
|
98
|
+
const entry = job;
|
|
99
|
+
const value = payload;
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
controllers.set(id, controller);
|
|
102
|
+
Promise.resolve().then(() => {
|
|
103
|
+
if (!input(value)) throw new Error("input did not satisfy input guard");
|
|
104
|
+
return handler(value, {
|
|
105
|
+
id: entry,
|
|
106
|
+
signal: controller.signal
|
|
107
|
+
});
|
|
108
|
+
}).then((result) => {
|
|
109
|
+
controllers.delete(id);
|
|
110
|
+
port.postMessage({
|
|
111
|
+
id,
|
|
112
|
+
ok: true,
|
|
113
|
+
value: result
|
|
114
|
+
});
|
|
115
|
+
}).catch((error) => {
|
|
116
|
+
controllers.delete(id);
|
|
117
|
+
let message = "worker operation failed";
|
|
118
|
+
try {
|
|
119
|
+
message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
} catch {}
|
|
121
|
+
try {
|
|
122
|
+
port.postMessage({
|
|
123
|
+
id,
|
|
124
|
+
ok: false,
|
|
125
|
+
error: message
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
try {
|
|
129
|
+
port.close();
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
6
136
|
//#region src/server/Thread.ts
|
|
7
137
|
/**
|
|
8
|
-
*
|
|
138
|
+
* Represents the internal mutable implementation of the readonly {@link NodeThread} observation
|
|
139
|
+
* contract.
|
|
9
140
|
*
|
|
10
141
|
* @remarks
|
|
11
142
|
* Liveness and the first terminal error live behind runtime-private fields. Thread `error`,
|
|
@@ -82,43 +213,57 @@ var Thread = class {
|
|
|
82
213
|
}
|
|
83
214
|
};
|
|
84
215
|
//#endregion
|
|
85
|
-
//#region src/server/
|
|
216
|
+
//#region src/server/Dispatch.ts
|
|
86
217
|
/**
|
|
87
|
-
*
|
|
218
|
+
* Represents one dispatched worker-thread job — the lifecycle entity behind a job posted to a
|
|
219
|
+
* leased {@link NodeThread}, whose {@link promise} settles with the narrowed reply.
|
|
88
220
|
*
|
|
89
221
|
* @remarks
|
|
90
|
-
*
|
|
91
|
-
*
|
|
222
|
+
* Mints a fresh per-dispatch correlation `id`, posts it with `job: context.id`, and settles
|
|
223
|
+
* when the thread replies for that correlation id. The stable Queue job id reaches the worker
|
|
224
|
+
* handler for idempotency across retries and restore; it is not caller identity or
|
|
225
|
+
* authentication / authorization evidence. Per-job consumer context is explicit,
|
|
226
|
+
* structured-cloneable `input`; ambient context is not worker-thread transport. A success
|
|
227
|
+
* `value` is narrowed through `result` (a value that fails the guard rejects — the zero-`as`
|
|
228
|
+
* type bridge); a failure rejects with the thread's error string. A thread that ALREADY died
|
|
229
|
+
* rejects synchronously at construction from the latched {@link NodeThread.death} — its death
|
|
230
|
+
* events fired before this dispatch existed and will never fire again, so waiting on the
|
|
231
|
+
* listeners would dangle forever; the latch makes death total across every event ordering. If
|
|
232
|
+
* the thread `error`s / `exit`s mid-flight the job rejects. On a `context.signal` abort it
|
|
233
|
+
* contains the cooperative `abort` post, evicts the thread, and observes `terminate()`
|
|
234
|
+
* settlement because CPU-bound work cannot honour the signal.
|
|
92
235
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!isRecord(value)) return false;
|
|
100
|
-
if (value.id !== id) return false;
|
|
101
|
-
if (value.ok === true) return "value" in value;
|
|
102
|
-
return value.ok === false && typeof value.error === "string";
|
|
103
|
-
});
|
|
104
|
-
return outcome.success && outcome.value;
|
|
105
|
-
}
|
|
106
|
-
//#endregion
|
|
107
|
-
//#region src/server/Dispatch.ts
|
|
108
|
-
/**
|
|
109
|
-
* Internal lifecycle entity for one dispatched worker-thread job.
|
|
236
|
+
* It owns stable `message` / `messageerror` / death listener identities, settlement,
|
|
237
|
+
* result-guard containment, and abort eviction for one dispatch. Deserialization failure, a
|
|
238
|
+
* matching-id malformed reply, and abort each evict and terminate the thread before rejecting,
|
|
239
|
+
* with termination failure preserved. Non-record, id-less, hostile-id, and foreign-id chatter
|
|
240
|
+
* is ignored. Every per-job listener (`message` / `messageerror` / `error` / `exit` / `abort`)
|
|
241
|
+
* is removed on settle.
|
|
110
242
|
*
|
|
111
|
-
* @
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
243
|
+
* Eviction reaches `alive` for a {@link NodeThread} this package produced. Against a
|
|
244
|
+
* consumer-supplied `NodeThread` an abort or a `messageerror` still terminates the supplied
|
|
245
|
+
* `worker` and rejects the job, and the implementer owns flipping its own `alive`.
|
|
246
|
+
*
|
|
247
|
+
* @typeParam TResult - The reply type the `result` guard narrows to
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* import { createThread, Dispatch } from '@orkestrel/worker/server'
|
|
252
|
+
*
|
|
253
|
+
* const isNumber = (value: unknown): value is number => typeof value === 'number'
|
|
254
|
+
*
|
|
255
|
+
* const thread = await createThread(new URL('./double.js', import.meta.url))
|
|
256
|
+
* const controller = new AbortController()
|
|
257
|
+
* const job = new Dispatch(thread, 21, { id: 'job-1', signal: controller.signal }, isNumber)
|
|
258
|
+
* console.log(await job.promise) // 42
|
|
259
|
+
* await thread.worker.terminate()
|
|
260
|
+
* ```
|
|
116
261
|
*/
|
|
117
262
|
var Dispatch = class {
|
|
118
263
|
#thread;
|
|
119
264
|
#worker;
|
|
120
265
|
#input;
|
|
121
|
-
#
|
|
266
|
+
#context;
|
|
122
267
|
#result;
|
|
123
268
|
#id = crypto.randomUUID();
|
|
124
269
|
#promise;
|
|
@@ -130,11 +275,11 @@ var Dispatch = class {
|
|
|
130
275
|
#exitHandler;
|
|
131
276
|
#abortHandler;
|
|
132
277
|
#settled = false;
|
|
133
|
-
constructor(thread, input,
|
|
278
|
+
constructor(thread, input, context, result) {
|
|
134
279
|
this.#thread = thread;
|
|
135
280
|
this.#worker = thread.worker;
|
|
136
281
|
this.#input = input;
|
|
137
|
-
this.#
|
|
282
|
+
this.#context = context;
|
|
138
283
|
this.#result = result;
|
|
139
284
|
const settlement = Promise.withResolvers();
|
|
140
285
|
this.#promise = settlement.promise;
|
|
@@ -159,15 +304,15 @@ var Dispatch = class {
|
|
|
159
304
|
this.#worker.on("messageerror", this.#messageErrorHandler);
|
|
160
305
|
this.#worker.on("error", this.#errorHandler);
|
|
161
306
|
this.#worker.on("exit", this.#exitHandler);
|
|
162
|
-
if (this.#
|
|
307
|
+
if (this.#context.signal.aborted) {
|
|
163
308
|
this.#abort();
|
|
164
309
|
return;
|
|
165
310
|
}
|
|
166
|
-
this.#
|
|
311
|
+
this.#context.signal.addEventListener("abort", this.#abortHandler, { once: true });
|
|
167
312
|
try {
|
|
168
313
|
this.#worker.postMessage({
|
|
169
314
|
id: this.#id,
|
|
170
|
-
job: this.#
|
|
315
|
+
job: this.#context.id,
|
|
171
316
|
command: "run",
|
|
172
317
|
input: this.#input
|
|
173
318
|
});
|
|
@@ -214,7 +359,7 @@ var Dispatch = class {
|
|
|
214
359
|
} catch (cause) {
|
|
215
360
|
notification.push(cause);
|
|
216
361
|
}
|
|
217
|
-
this.#terminate(this.#
|
|
362
|
+
this.#terminate(this.#context.signal.reason, notification);
|
|
218
363
|
}
|
|
219
364
|
#terminate(error, notification = []) {
|
|
220
365
|
if (this.#settled) return;
|
|
@@ -258,183 +403,21 @@ var Dispatch = class {
|
|
|
258
403
|
this.#worker.off("messageerror", this.#messageErrorHandler);
|
|
259
404
|
this.#worker.off("error", this.#errorHandler);
|
|
260
405
|
this.#worker.off("exit", this.#exitHandler);
|
|
261
|
-
this.#
|
|
406
|
+
this.#context.signal.removeEventListener("abort", this.#abortHandler);
|
|
262
407
|
}
|
|
263
408
|
};
|
|
264
409
|
//#endregion
|
|
265
|
-
//#region src/server/helpers.ts
|
|
266
|
-
/**
|
|
267
|
-
* Spawn one worker thread and resolve a live {@link NodeThread} once it comes online.
|
|
268
|
-
*
|
|
269
|
-
* @remarks
|
|
270
|
-
* Constructs the thread with the `script` module and the cloned `workerData`, then
|
|
271
|
-
* resolves on the thread's `online` event (rejecting on an early `error` OR an `exit`
|
|
272
|
-
* that arrives before `online`, so the spawn promise is total — it can never dangle on a
|
|
273
|
-
* thread that died without erroring). The wrapper attaches persistent `error` / `exit`
|
|
274
|
-
* listeners that flip `alive` to `false` AND latch the first terminal event on
|
|
275
|
-
* {@link NodeThread.death}: a crash is observable to an in-flight {@link dispatch} (via
|
|
276
|
-
* its own listeners), to the pool's `validate` (via `alive`), and — crucially — to a
|
|
277
|
-
* dispatch that attaches AFTER the death (via the latch). A `messageerror` is terminal too,
|
|
278
|
-
* so a thread whose inbound payload could not be deserialized is never reused. The latch
|
|
279
|
-
* closes a real race: a thread can become terminal before the readiness promise continuation
|
|
280
|
-
* hands it to `dispatch`, leaving no future death event for that dispatch to observe. Without
|
|
281
|
-
* the latch, that job would wait forever. The pool's `create` hook calls this.
|
|
282
|
-
*
|
|
283
|
-
* @param script - The worker module each thread runs (must call `serveWorker`)
|
|
284
|
-
* @param workerData - Opaque, structured-cloneable data handed to the thread at spawn
|
|
285
|
-
* @returns A promise resolving the online {@link NodeThread}
|
|
286
|
-
*/
|
|
287
|
-
function spawnThread(script, workerData) {
|
|
288
|
-
return new Thread(script, workerData).promise;
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Dispatch one job to a leased {@link NodeThread} and await its narrowed reply.
|
|
292
|
-
*
|
|
293
|
-
* @remarks
|
|
294
|
-
* Mints a fresh per-dispatch correlation `id`, posts it with `job: execution.id`, and
|
|
295
|
-
* resolves when the thread replies for that correlation id. The stable Queue job id reaches
|
|
296
|
-
* the worker handler for idempotency across retries and restore; it is not caller identity or
|
|
297
|
-
* authentication / authorization evidence. Per-job consumer context remains explicit,
|
|
298
|
-
* structured-cloneable `input`; ambient context is not worker-thread transport. A success
|
|
299
|
-
* `value` is narrowed through `result` (a value that fails the guard
|
|
300
|
-
* rejects — the zero-`as` type bridge), a failure rejects with the thread's error string.
|
|
301
|
-
* A thread that ALREADY died rejects synchronously at entry from the latched
|
|
302
|
-
* {@link NodeThread.death} — its death events fired before this dispatch existed and will
|
|
303
|
-
* never fire again, so waiting on the listeners below would dangle forever; the latch makes
|
|
304
|
-
* death total across every event ordering. If the thread `error`s / `exit`s mid-flight it is
|
|
305
|
-
* marked dead and the
|
|
306
|
-
* job rejects. An inbound `messageerror` also evicts and terminates the thread before
|
|
307
|
-
* rejection. On `execution.signal` abort it contains the cooperative `abort` post,
|
|
308
|
-
* evicts the thread, and observes `terminate()` settlement because CPU-bound work cannot
|
|
309
|
-
* honour the signal; the freed pool slot then gets a fresh thread. Every per-job listener
|
|
310
|
-
* (`message` / `messageerror` / `error` / `exit` / `abort`) is removed on settle.
|
|
311
|
-
*
|
|
312
|
-
* @typeParam TResult - The reply type the `result` guard narrows to
|
|
313
|
-
* @param thread - The leased thread to run the job on
|
|
314
|
-
* @param input - The work payload (structured-cloned to the thread)
|
|
315
|
-
* @param execution - The per-attempt handle; its `signal` aborts → terminate + evict
|
|
316
|
-
* @param result - The {@link Guard} narrowing the reply value with no assertion
|
|
317
|
-
* @returns A promise resolving the narrowed `TResult`, or rejecting on error / abort
|
|
318
|
-
*/
|
|
319
|
-
function dispatch(thread, input, execution, result) {
|
|
320
|
-
return new Dispatch(thread, input, execution, result).promise;
|
|
321
|
-
}
|
|
322
|
-
//#endregion
|
|
323
|
-
//#region src/server/handlers.ts
|
|
324
|
-
/**
|
|
325
|
-
* Register a worker-thread handler — the worker-side half of {@link createNodeWorker}.
|
|
326
|
-
*
|
|
327
|
-
* @remarks
|
|
328
|
-
* Must be the spawned thread's module entry. It listens on the parent port for the
|
|
329
|
-
* run/abort protocol: a `run` message narrows its `input` through `options.input` (an
|
|
330
|
-
* invalid payload replies with an error envelope, never running the handler), then runs
|
|
331
|
-
* `options.handler(input, { id: job, signal })` and replies `{ id, ok: true, value }` on success or
|
|
332
|
-
* `{ id, ok: false, error }` on throw. Input-guard throws use the same failure envelope. If a
|
|
333
|
-
* success value cannot be cloned, the post is retried as a clone-safe failure; if that post also
|
|
334
|
-
* fails, the parent port closes so the main side observes thread exit instead of waiting forever.
|
|
335
|
-
* The run envelope's `id` is fresh per dispatch and keys controllers, aborts, and replies;
|
|
336
|
-
* its `job` is the stable Queue idempotency key exposed as `execution.id` across retries
|
|
337
|
-
* and restore. That job id identifies work, not a caller, and is not authentication or
|
|
338
|
-
* authorization evidence. Each attempt has its own `AbortController`, so an `abort`
|
|
339
|
-
* message for the correlation id fires the handler's `signal` (cooperative — the main
|
|
340
|
-
* side ALSO terminates the thread, so a handler that ignores its signal is still stopped).
|
|
341
|
-
* Every inbound message is narrowed with the inlined guards — no `as`. On the main thread
|
|
342
|
-
* (`parentPort === null`) it is a no-op.
|
|
343
|
-
*
|
|
344
|
-
* @typeParam TInput - The work payload (inferred from `options.input`)
|
|
345
|
-
* @typeParam TResult - The value the handler resolves (the reply payload)
|
|
346
|
-
* @param options - The `input` guard and the `handler` (see {@link ServeWorkerOptions})
|
|
347
|
-
*
|
|
348
|
-
* @example
|
|
349
|
-
* ```ts
|
|
350
|
-
* // double.ts — a worker script
|
|
351
|
-
* import { serveWorker } from '@orkestrel/worker/server'
|
|
352
|
-
*
|
|
353
|
-
* serveWorker<number, number>({
|
|
354
|
-
* input: (value): value is number => typeof value === 'number',
|
|
355
|
-
* handler: (value) => value * 2,
|
|
356
|
-
* })
|
|
357
|
-
* ```
|
|
358
|
-
*/
|
|
359
|
-
function serveWorker(options) {
|
|
360
|
-
const port = parentPort;
|
|
361
|
-
if (port === null) return;
|
|
362
|
-
const input = options.input;
|
|
363
|
-
const handler = options.handler;
|
|
364
|
-
const controllers = /* @__PURE__ */ new Map();
|
|
365
|
-
port.on("message", (raw) => {
|
|
366
|
-
let command;
|
|
367
|
-
let correlation;
|
|
368
|
-
let job;
|
|
369
|
-
let payload;
|
|
370
|
-
let carried = false;
|
|
371
|
-
try {
|
|
372
|
-
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
373
|
-
if ("command" in raw) command = raw.command;
|
|
374
|
-
if ("id" in raw) correlation = raw.id;
|
|
375
|
-
if ("job" in raw) job = raw.job;
|
|
376
|
-
if ("input" in raw) {
|
|
377
|
-
payload = raw.input;
|
|
378
|
-
carried = true;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
} catch {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
if (typeof correlation !== "string") return;
|
|
385
|
-
const id = correlation;
|
|
386
|
-
if (command === "abort") {
|
|
387
|
-
controllers.get(id)?.abort();
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (command !== "run" || typeof job !== "string" || !carried) return;
|
|
391
|
-
const execution = job;
|
|
392
|
-
const value = payload;
|
|
393
|
-
const controller = new AbortController();
|
|
394
|
-
controllers.set(id, controller);
|
|
395
|
-
Promise.resolve().then(() => {
|
|
396
|
-
if (!input(value)) throw new Error("input did not satisfy input guard");
|
|
397
|
-
return handler(value, {
|
|
398
|
-
id: execution,
|
|
399
|
-
signal: controller.signal
|
|
400
|
-
});
|
|
401
|
-
}).then((result) => {
|
|
402
|
-
controllers.delete(id);
|
|
403
|
-
port.postMessage({
|
|
404
|
-
id,
|
|
405
|
-
ok: true,
|
|
406
|
-
value: result
|
|
407
|
-
});
|
|
408
|
-
}).catch((error) => {
|
|
409
|
-
controllers.delete(id);
|
|
410
|
-
let message = "worker operation failed";
|
|
411
|
-
try {
|
|
412
|
-
message = error instanceof Error ? error.message : String(error);
|
|
413
|
-
} catch {}
|
|
414
|
-
try {
|
|
415
|
-
port.postMessage({
|
|
416
|
-
id,
|
|
417
|
-
ok: false,
|
|
418
|
-
error: message
|
|
419
|
-
});
|
|
420
|
-
} catch {
|
|
421
|
-
try {
|
|
422
|
-
port.close();
|
|
423
|
-
} catch {}
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
//#endregion
|
|
429
410
|
//#region src/server/NodeWorker.ts
|
|
430
411
|
/**
|
|
431
|
-
*
|
|
412
|
+
* Represents the internal composition entity backing {@link createNodeWorker}.
|
|
432
413
|
*
|
|
433
414
|
* @remarks
|
|
434
415
|
* Supplies bound Pool and Queue operations without nested function assignments. The resulting
|
|
435
|
-
* public entity
|
|
416
|
+
* public entity is the plain core {@link WorkerInterface}.
|
|
436
417
|
*/
|
|
437
418
|
var NodeWorker = class {
|
|
419
|
+
#on;
|
|
420
|
+
#error;
|
|
438
421
|
#script;
|
|
439
422
|
#input;
|
|
440
423
|
#result;
|
|
@@ -444,6 +427,8 @@ var NodeWorker = class {
|
|
|
444
427
|
#timeout;
|
|
445
428
|
#store;
|
|
446
429
|
constructor(options) {
|
|
430
|
+
this.#on = options.on;
|
|
431
|
+
this.#error = options.error;
|
|
447
432
|
this.#script = options.script;
|
|
448
433
|
this.#input = options.input;
|
|
449
434
|
this.#result = options.result;
|
|
@@ -462,6 +447,8 @@ var NodeWorker = class {
|
|
|
462
447
|
...this.#concurrency !== void 0 ? { max: this.#concurrency } : {}
|
|
463
448
|
},
|
|
464
449
|
handler: this.#handle.bind(this),
|
|
450
|
+
...this.#on !== void 0 ? { on: this.#on } : {},
|
|
451
|
+
...this.#error !== void 0 ? { error: this.#error } : {},
|
|
465
452
|
...this.#concurrency !== void 0 ? { concurrency: this.#concurrency } : {},
|
|
466
453
|
...this.#retries !== void 0 ? { retries: this.#retries } : {},
|
|
467
454
|
...this.#timeout !== void 0 ? { timeout: this.#timeout } : {},
|
|
@@ -469,7 +456,7 @@ var NodeWorker = class {
|
|
|
469
456
|
});
|
|
470
457
|
}
|
|
471
458
|
#create() {
|
|
472
|
-
return
|
|
459
|
+
return new Thread(this.#script, this.#workerData).promise;
|
|
473
460
|
}
|
|
474
461
|
async #destroy(thread) {
|
|
475
462
|
await thread.worker.terminate();
|
|
@@ -477,21 +464,55 @@ var NodeWorker = class {
|
|
|
477
464
|
#validate(thread) {
|
|
478
465
|
return thread.alive && thread.worker.threadId > 0;
|
|
479
466
|
}
|
|
480
|
-
#handle(input, thread,
|
|
467
|
+
#handle(input, thread, context) {
|
|
481
468
|
const outcome = attempt(() => this.#input(input));
|
|
482
469
|
if (!outcome.success) return Promise.reject(outcome.error);
|
|
483
470
|
if (!outcome.value) return Promise.reject(/* @__PURE__ */ new Error("input did not satisfy input guard"));
|
|
484
|
-
return
|
|
471
|
+
return new Dispatch(thread, input, context, this.#result).promise;
|
|
485
472
|
}
|
|
486
473
|
};
|
|
487
474
|
//#endregion
|
|
488
475
|
//#region src/server/factories.ts
|
|
489
476
|
/**
|
|
490
|
-
*
|
|
477
|
+
* Creates one live worker thread and resolves it as a {@link NodeThread} after it comes
|
|
478
|
+
* online.
|
|
479
|
+
*
|
|
480
|
+
* @remarks
|
|
481
|
+
* Constructs the thread with the `script` module and the cloned `workerData`, then
|
|
482
|
+
* resolves on the thread's `online` event (rejecting on an early `error` OR an `exit`
|
|
483
|
+
* that arrives before `online`, so the spawn promise is total — it can never dangle on a
|
|
484
|
+
* thread that died without erroring). The returned entity attaches persistent `error` /
|
|
485
|
+
* `exit` listeners that flip `alive` to `false` AND latch the first terminal event on
|
|
486
|
+
* {@link NodeThread.death}: a crash is observable to an in-flight {@link Dispatch} (through
|
|
487
|
+
* its own listeners), to a pool's `validate` (through `alive`), and — crucially — to a
|
|
488
|
+
* dispatch that attaches AFTER the death (through the latch). A `messageerror` is terminal
|
|
489
|
+
* too, so a thread whose inbound payload could not be deserialized is never reused. The latch
|
|
490
|
+
* closes a real race: a thread can become terminal before the readiness promise continuation
|
|
491
|
+
* hands it to a {@link Dispatch}, leaving no future death event for that dispatch to observe.
|
|
492
|
+
* Without the latch, that job would wait forever. {@link createNodeWorker} spawns its pooled
|
|
493
|
+
* threads the same way; reach for this to drive one thread yourself.
|
|
494
|
+
*
|
|
495
|
+
* @param script - The worker module the thread runs (its module must call `serveWorker`)
|
|
496
|
+
* @param workerData - Opaque, structured-cloneable data handed to the thread at spawn
|
|
497
|
+
* @returns A promise resolving the online {@link NodeThread}
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* ```ts
|
|
501
|
+
* import { createThread } from '@orkestrel/worker/server'
|
|
502
|
+
*
|
|
503
|
+
* const thread = await createThread(new URL('./double.js', import.meta.url))
|
|
504
|
+
* await thread.worker.terminate()
|
|
505
|
+
* ```
|
|
506
|
+
*/
|
|
507
|
+
function createThread(script, workerData) {
|
|
508
|
+
return new Thread(script, workerData).promise;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Creates a persistent JSON-file {@link QueueStoreInterface} — the core
|
|
491
512
|
* `createDatabaseQueueStore` over a server {@link createJSONDriver}.
|
|
492
513
|
*
|
|
493
514
|
* @remarks
|
|
494
|
-
* A queue's durable state is
|
|
515
|
+
* A queue's durable state is a database table, so JSON persistence reuses the
|
|
495
516
|
* existing JSON-file driver rather than a bespoke store: the entries are written to
|
|
496
517
|
* (and reloaded from) the file at `path`, surviving a process restart. There is no new
|
|
497
518
|
* class — the store engine ({@link createDatabaseQueueStore}) is shared, and only the
|
|
@@ -520,20 +541,21 @@ function createJSONQueueStore(path, input) {
|
|
|
520
541
|
return createDatabaseQueueStore(input, createJSONDriver(path));
|
|
521
542
|
}
|
|
522
543
|
/**
|
|
523
|
-
*
|
|
544
|
+
* Creates a CPU-parallel worker over `node:worker_threads` — a thin specialization of the
|
|
524
545
|
* core `createWorker` whose pooled resource is a worker THREAD.
|
|
525
546
|
*
|
|
526
547
|
* @remarks
|
|
527
548
|
* Composition, not reimplementation: all concurrency, retries, per-attempt timeout,
|
|
528
549
|
* lifecycle, and durability are the core `Worker`'s (a `Queue` ⨉ `Pool`). This factory
|
|
529
|
-
* supplies only the thread pairing — the pool `create`s a thread (
|
|
530
|
-
* `destroy`s it with `terminate()`, and `validate`s it by
|
|
531
|
-
* evicted / crashed thread is dropped and replaced) — and an
|
|
532
|
-
* narrows the input through `options.input` (fail-fast before the
|
|
533
|
-
* boundary) then
|
|
550
|
+
* supplies only the thread pairing — the pool `create`s a thread (the same spawn
|
|
551
|
+
* {@link createThread} publishes), `destroy`s it with `terminate()`, and `validate`s it by
|
|
552
|
+
* `alive && threadId > 0` (so an evicted / crashed thread is dropped and replaced) — and an
|
|
553
|
+
* internal handler that narrows the input through `options.input` (fail-fast before the
|
|
554
|
+
* structured-clone boundary) then runs a {@link Dispatch} against the leased thread,
|
|
555
|
+
* narrowing the reply through
|
|
534
556
|
* `options.result`. Both generics INFER from the `input` / `result` guards, so call sites
|
|
535
557
|
* need no explicit type arguments. The boundary is crossed with ZERO `as`: the guards
|
|
536
|
-
* reconstruct `TInput` / `TResult` by validation
|
|
558
|
+
* reconstruct `TInput` / `TResult` by validation. An `abort` / `timeout`
|
|
537
559
|
* TERMINATES the in-flight thread (CPU-bound work can't honour a signal) and evicts it; a
|
|
538
560
|
* subsequent job spawns a fresh thread. The worker script's module must call
|
|
539
561
|
* `serveWorker`. Returns the plain {@link WorkerInterface} — its methods are the Worker's.
|
|
@@ -541,7 +563,7 @@ function createJSONQueueStore(path, input) {
|
|
|
541
563
|
* @typeParam TInput - The work payload each job carries (inferred from `input`)
|
|
542
564
|
* @typeParam TResult - The value a thread resolves for a job (inferred from `result`)
|
|
543
565
|
* @param options - The `script` plus the `input` / `result` guards and optional
|
|
544
|
-
* `workerData` / `concurrency` / `retries` / `timeout` / `store`
|
|
566
|
+
* `on` / `error` / `workerData` / `concurrency` / `retries` / `timeout` / `store`
|
|
545
567
|
* (see {@link NodeWorkerOptions})
|
|
546
568
|
* @returns A working {@link WorkerInterface} backed by a thread pool
|
|
547
569
|
*
|
|
@@ -564,6 +586,6 @@ function createNodeWorker(options) {
|
|
|
564
586
|
return new NodeWorker(options).build();
|
|
565
587
|
}
|
|
566
588
|
//#endregion
|
|
567
|
-
export { createJSONQueueStore, createNodeWorker,
|
|
589
|
+
export { Dispatch, createJSONQueueStore, createNodeWorker, createThread, isReply, serveWorker };
|
|
568
590
|
|
|
569
591
|
//# sourceMappingURL=index.js.map
|