@orkestrel/worker 0.0.3 → 0.0.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.
- package/README.md +3 -2
- package/dist/src/core/index.cjs +65 -31
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +47 -22
- package/dist/src/core/index.d.ts +47 -22
- package/dist/src/core/index.js +65 -31
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +368 -175
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +44 -58
- package/dist/src/server/index.d.ts +44 -58
- package/dist/src/server/index.js +368 -175
- package/dist/src/server/index.js.map +1 -1
- package/package.json +22 -19
package/dist/src/server/index.js
CHANGED
|
@@ -1,8 +1,266 @@
|
|
|
1
|
+
import { attempt, isRecord } from "@orkestrel/contract";
|
|
1
2
|
import { Worker, parentPort } from "node:worker_threads";
|
|
2
|
-
import { isRecord } from "@orkestrel/contract";
|
|
3
|
-
import { createWorker } from "../core/index.js";
|
|
4
3
|
import { createJSONDriver } from "@orkestrel/database/server";
|
|
5
4
|
import { createDatabaseQueueStore } from "@orkestrel/queue";
|
|
5
|
+
import { createWorker } from "../core/index.js";
|
|
6
|
+
//#region src/server/Thread.ts
|
|
7
|
+
/**
|
|
8
|
+
* Internal mutable implementation of the readonly {@link NodeThread} observation contract.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Liveness and the first terminal error live behind runtime-private fields. Thread `error`,
|
|
12
|
+
* `messageerror`, and `exit` all latch death, so pool validation cannot reuse a thread whose
|
|
13
|
+
* inbound message could not be deserialized.
|
|
14
|
+
*/
|
|
15
|
+
var Thread = class {
|
|
16
|
+
#worker;
|
|
17
|
+
#promise;
|
|
18
|
+
#resolve;
|
|
19
|
+
#reject;
|
|
20
|
+
#recordHandler;
|
|
21
|
+
#recordExitHandler;
|
|
22
|
+
#onlineHandler;
|
|
23
|
+
#spawnErrorHandler;
|
|
24
|
+
#spawnExitHandler;
|
|
25
|
+
#alive = true;
|
|
26
|
+
#death;
|
|
27
|
+
constructor(script, workerData) {
|
|
28
|
+
this.#worker = new Worker(script, { ...workerData !== void 0 ? { workerData } : {} });
|
|
29
|
+
const readiness = Promise.withResolvers();
|
|
30
|
+
this.#promise = readiness.promise;
|
|
31
|
+
this.#resolve = readiness.resolve;
|
|
32
|
+
this.#reject = readiness.reject;
|
|
33
|
+
this.#recordHandler = this.#record.bind(this);
|
|
34
|
+
this.#recordExitHandler = this.#recordExit.bind(this);
|
|
35
|
+
this.#onlineHandler = this.#online.bind(this);
|
|
36
|
+
this.#spawnErrorHandler = this.#spawnError.bind(this);
|
|
37
|
+
this.#spawnExitHandler = this.#spawnExit.bind(this);
|
|
38
|
+
this.#worker.on("error", this.#recordHandler);
|
|
39
|
+
this.#worker.on("messageerror", this.#recordHandler);
|
|
40
|
+
this.#worker.on("exit", this.#recordExitHandler);
|
|
41
|
+
this.#worker.once("online", this.#onlineHandler);
|
|
42
|
+
this.#worker.once("error", this.#spawnErrorHandler);
|
|
43
|
+
this.#worker.once("exit", this.#spawnExitHandler);
|
|
44
|
+
}
|
|
45
|
+
get worker() {
|
|
46
|
+
return this.#worker;
|
|
47
|
+
}
|
|
48
|
+
get alive() {
|
|
49
|
+
return this.#alive;
|
|
50
|
+
}
|
|
51
|
+
get death() {
|
|
52
|
+
return this.#death;
|
|
53
|
+
}
|
|
54
|
+
get promise() {
|
|
55
|
+
return this.#promise;
|
|
56
|
+
}
|
|
57
|
+
evict() {
|
|
58
|
+
this.#alive = false;
|
|
59
|
+
}
|
|
60
|
+
#record(error) {
|
|
61
|
+
this.#alive = false;
|
|
62
|
+
if (this.#death === void 0) this.#death = error;
|
|
63
|
+
}
|
|
64
|
+
#recordExit(code) {
|
|
65
|
+
this.#alive = false;
|
|
66
|
+
if (this.#death === void 0) this.#death = /* @__PURE__ */ new Error(`worker thread exited (code ${String(code)})`);
|
|
67
|
+
}
|
|
68
|
+
#online() {
|
|
69
|
+
this.#worker.off("error", this.#spawnErrorHandler);
|
|
70
|
+
this.#worker.off("exit", this.#spawnExitHandler);
|
|
71
|
+
this.#resolve(this);
|
|
72
|
+
}
|
|
73
|
+
#spawnError(error) {
|
|
74
|
+
this.#worker.off("online", this.#onlineHandler);
|
|
75
|
+
this.#worker.off("exit", this.#spawnExitHandler);
|
|
76
|
+
this.#reject(error);
|
|
77
|
+
}
|
|
78
|
+
#spawnExit(code) {
|
|
79
|
+
this.#worker.off("online", this.#onlineHandler);
|
|
80
|
+
this.#worker.off("error", this.#spawnErrorHandler);
|
|
81
|
+
this.#reject(/* @__PURE__ */ new Error(`worker thread exited before coming online (code ${String(code)})`));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/server/validators.ts
|
|
86
|
+
/**
|
|
87
|
+
* Narrow an inbound `message` to a {@link Reply} for a given job `id` — no assertion.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* A total predicate: a record whose `id` matches and whose `ok` discriminant is well-formed.
|
|
91
|
+
* Anything else is rejected so a dispatch listener can ignore foreign or malformed messages.
|
|
92
|
+
*
|
|
93
|
+
* @param value - The inbound message to narrow
|
|
94
|
+
* @param id - The job id a matching reply must carry
|
|
95
|
+
* @returns `true` when the value is this job's well-formed reply
|
|
96
|
+
*/
|
|
97
|
+
function isReply(value, id) {
|
|
98
|
+
const outcome = attempt(() => {
|
|
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.
|
|
110
|
+
*
|
|
111
|
+
* @remarks
|
|
112
|
+
* Owns stable `message` / `messageerror` / death listener identities, settlement, result-guard
|
|
113
|
+
* containment, and abort eviction for one dispatch. Deserialization failure, a matching-id
|
|
114
|
+
* malformed reply, and abort each evict and terminate the thread before rejecting, with
|
|
115
|
+
* termination failure preserved. Non-record, id-less, hostile-id, and foreign-id chatter is ignored.
|
|
116
|
+
*/
|
|
117
|
+
var Dispatch = class {
|
|
118
|
+
#thread;
|
|
119
|
+
#worker;
|
|
120
|
+
#input;
|
|
121
|
+
#execution;
|
|
122
|
+
#result;
|
|
123
|
+
#id = crypto.randomUUID();
|
|
124
|
+
#promise;
|
|
125
|
+
#fulfill;
|
|
126
|
+
#reject;
|
|
127
|
+
#messageHandler;
|
|
128
|
+
#messageErrorHandler;
|
|
129
|
+
#errorHandler;
|
|
130
|
+
#exitHandler;
|
|
131
|
+
#abortHandler;
|
|
132
|
+
#settled = false;
|
|
133
|
+
constructor(thread, input, execution, result) {
|
|
134
|
+
this.#thread = thread;
|
|
135
|
+
this.#worker = thread.worker;
|
|
136
|
+
this.#input = input;
|
|
137
|
+
this.#execution = execution;
|
|
138
|
+
this.#result = result;
|
|
139
|
+
const settlement = Promise.withResolvers();
|
|
140
|
+
this.#promise = settlement.promise;
|
|
141
|
+
this.#fulfill = settlement.resolve;
|
|
142
|
+
this.#reject = settlement.reject;
|
|
143
|
+
this.#messageHandler = this.#message.bind(this);
|
|
144
|
+
this.#messageErrorHandler = this.#messageError.bind(this);
|
|
145
|
+
this.#errorHandler = this.#error.bind(this);
|
|
146
|
+
this.#exitHandler = this.#exit.bind(this);
|
|
147
|
+
this.#abortHandler = this.#abort.bind(this);
|
|
148
|
+
this.#start();
|
|
149
|
+
}
|
|
150
|
+
get promise() {
|
|
151
|
+
return this.#promise;
|
|
152
|
+
}
|
|
153
|
+
#start() {
|
|
154
|
+
if (this.#thread.death !== void 0 || !this.#thread.alive) {
|
|
155
|
+
this.#fail(this.#thread.death ?? /* @__PURE__ */ new Error("worker thread is dead"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.#worker.on("message", this.#messageHandler);
|
|
159
|
+
this.#worker.on("messageerror", this.#messageErrorHandler);
|
|
160
|
+
this.#worker.on("error", this.#errorHandler);
|
|
161
|
+
this.#worker.on("exit", this.#exitHandler);
|
|
162
|
+
if (this.#execution.signal.aborted) {
|
|
163
|
+
this.#abort();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.#execution.signal.addEventListener("abort", this.#abortHandler, { once: true });
|
|
167
|
+
try {
|
|
168
|
+
this.#worker.postMessage({
|
|
169
|
+
id: this.#id,
|
|
170
|
+
command: "run",
|
|
171
|
+
input: this.#input
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.#fail(error instanceof Error ? error : new Error(String(error)));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
#message(value) {
|
|
178
|
+
if (!isRecord(value)) return;
|
|
179
|
+
const id = attempt(() => value.id);
|
|
180
|
+
if (!id.success || id.value !== this.#id) return;
|
|
181
|
+
if (!isReply(value, this.#id)) {
|
|
182
|
+
this.#terminate(/* @__PURE__ */ new Error("worker reply was malformed"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (value.ok) {
|
|
186
|
+
const reply = value.value;
|
|
187
|
+
try {
|
|
188
|
+
if (this.#result(reply)) this.#succeed(reply);
|
|
189
|
+
else this.#fail(/* @__PURE__ */ new Error("reply did not satisfy result guard"));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.#fail(error);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.#fail(new Error(value.error));
|
|
196
|
+
}
|
|
197
|
+
#messageError(error) {
|
|
198
|
+
this.#terminate(error);
|
|
199
|
+
}
|
|
200
|
+
#error(error) {
|
|
201
|
+
this.#fail(error);
|
|
202
|
+
}
|
|
203
|
+
#exit() {
|
|
204
|
+
this.#fail(this.#thread.death ?? /* @__PURE__ */ new Error("worker thread exited"));
|
|
205
|
+
}
|
|
206
|
+
#abort() {
|
|
207
|
+
const notification = [];
|
|
208
|
+
try {
|
|
209
|
+
this.#worker.postMessage({
|
|
210
|
+
id: this.#id,
|
|
211
|
+
command: "abort"
|
|
212
|
+
});
|
|
213
|
+
} catch (cause) {
|
|
214
|
+
notification.push(cause);
|
|
215
|
+
}
|
|
216
|
+
this.#terminate(this.#execution.signal.reason, notification);
|
|
217
|
+
}
|
|
218
|
+
#terminate(error, notification = []) {
|
|
219
|
+
if (this.#settled) return;
|
|
220
|
+
this.#settled = true;
|
|
221
|
+
this.#detach();
|
|
222
|
+
if (this.#thread instanceof Thread) this.#thread.evict();
|
|
223
|
+
let termination;
|
|
224
|
+
try {
|
|
225
|
+
termination = this.#worker.terminate();
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
this.#reject(new AggregateError([
|
|
228
|
+
error,
|
|
229
|
+
...notification,
|
|
230
|
+
cause
|
|
231
|
+
], "worker termination failed"));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
termination.then(() => {
|
|
235
|
+
if (notification.length === 0) this.#reject(error);
|
|
236
|
+
else this.#reject(new AggregateError([error, ...notification], "worker abort notification failed"));
|
|
237
|
+
}, (cause) => this.#reject(new AggregateError([
|
|
238
|
+
error,
|
|
239
|
+
...notification,
|
|
240
|
+
cause
|
|
241
|
+
], "worker termination failed")));
|
|
242
|
+
}
|
|
243
|
+
#succeed(value) {
|
|
244
|
+
if (this.#settled) return;
|
|
245
|
+
this.#settled = true;
|
|
246
|
+
this.#detach();
|
|
247
|
+
this.#fulfill(value);
|
|
248
|
+
}
|
|
249
|
+
#fail(error) {
|
|
250
|
+
if (this.#settled) return;
|
|
251
|
+
this.#settled = true;
|
|
252
|
+
this.#detach();
|
|
253
|
+
this.#reject(error);
|
|
254
|
+
}
|
|
255
|
+
#detach() {
|
|
256
|
+
this.#worker.off("message", this.#messageHandler);
|
|
257
|
+
this.#worker.off("messageerror", this.#messageErrorHandler);
|
|
258
|
+
this.#worker.off("error", this.#errorHandler);
|
|
259
|
+
this.#worker.off("exit", this.#exitHandler);
|
|
260
|
+
this.#execution.signal.removeEventListener("abort", this.#abortHandler);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
6
264
|
//#region src/server/helpers.ts
|
|
7
265
|
/**
|
|
8
266
|
* Spawn one worker thread and resolve a live {@link NodeThread} once it comes online.
|
|
@@ -15,71 +273,18 @@ import { createDatabaseQueueStore } from "@orkestrel/queue";
|
|
|
15
273
|
* listeners that flip `alive` to `false` AND latch the first terminal event on
|
|
16
274
|
* {@link NodeThread.death}: a crash is observable to an in-flight {@link dispatch} (via
|
|
17
275
|
* its own listeners), to the pool's `validate` (via `alive`), and — crucially — to a
|
|
18
|
-
* dispatch that attaches AFTER the death (via the latch).
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
276
|
+
* dispatch that attaches AFTER the death (via the latch). A `messageerror` is terminal too,
|
|
277
|
+
* so a thread whose inbound payload could not be deserialized is never reused. The latch
|
|
278
|
+
* closes a real race: a thread can become terminal before the readiness promise continuation
|
|
279
|
+
* hands it to `dispatch`, leaving no future death event for that dispatch to observe. Without
|
|
280
|
+
* the latch, that job would wait forever. The pool's `create` hook calls this.
|
|
23
281
|
*
|
|
24
282
|
* @param script - The worker module each thread runs (must call `serveWorker`)
|
|
25
283
|
* @param workerData - Opaque, structured-cloneable data handed to the thread at spawn
|
|
26
284
|
* @returns A promise resolving the online {@link NodeThread}
|
|
27
285
|
*/
|
|
28
286
|
function spawnThread(script, workerData) {
|
|
29
|
-
|
|
30
|
-
const thread = {
|
|
31
|
-
worker,
|
|
32
|
-
alive: true,
|
|
33
|
-
death: void 0
|
|
34
|
-
};
|
|
35
|
-
worker.on("error", (error) => {
|
|
36
|
-
thread.alive = false;
|
|
37
|
-
if (thread.death === void 0) thread.death = error;
|
|
38
|
-
});
|
|
39
|
-
worker.on("exit", (code) => {
|
|
40
|
-
thread.alive = false;
|
|
41
|
-
if (thread.death === void 0) thread.death = /* @__PURE__ */ new Error(`worker thread exited (code ${code})`);
|
|
42
|
-
});
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
const onOnline = () => {
|
|
45
|
-
worker.off("error", onError);
|
|
46
|
-
worker.off("exit", onExit);
|
|
47
|
-
resolve(thread);
|
|
48
|
-
};
|
|
49
|
-
const onError = (error) => {
|
|
50
|
-
worker.off("online", onOnline);
|
|
51
|
-
worker.off("exit", onExit);
|
|
52
|
-
reject(error);
|
|
53
|
-
};
|
|
54
|
-
const onExit = (code) => {
|
|
55
|
-
worker.off("online", onOnline);
|
|
56
|
-
worker.off("error", onError);
|
|
57
|
-
reject(/* @__PURE__ */ new Error(`worker thread exited before coming online (code ${code})`));
|
|
58
|
-
};
|
|
59
|
-
worker.once("online", onOnline);
|
|
60
|
-
worker.once("error", onError);
|
|
61
|
-
worker.once("exit", onExit);
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Narrow an inbound `message` to a {@link Reply} for a given job `id` — no assertion.
|
|
66
|
-
*
|
|
67
|
-
* @remarks
|
|
68
|
-
* A total {@link Guard}-style predicate (never throws): a record whose `id` matches and
|
|
69
|
-
* whose `ok` discriminant is well-formed (a `true` carries any `value`; a `false` carries a
|
|
70
|
-
* string `error`). Anything else — another job's reply, a malformed payload — is `false`, so
|
|
71
|
-
* a {@link dispatch} listener ignores it (a thread that chatters on the channel can't corrupt
|
|
72
|
-
* a job).
|
|
73
|
-
*
|
|
74
|
-
* @param value - The inbound message to narrow
|
|
75
|
-
* @param id - The job id a matching reply must carry
|
|
76
|
-
* @returns `true` (narrowing `value` to {@link Reply}) when it is this job's well-formed reply
|
|
77
|
-
*/
|
|
78
|
-
function isReply(value, id) {
|
|
79
|
-
if (!isRecord(value)) return false;
|
|
80
|
-
if (value.id !== id) return false;
|
|
81
|
-
if (value.ok === true) return true;
|
|
82
|
-
return value.ok === false && typeof value.error === "string";
|
|
287
|
+
return new Thread(script, workerData).promise;
|
|
83
288
|
}
|
|
84
289
|
/**
|
|
85
290
|
* Dispatch one job to a leased {@link NodeThread} and await its narrowed reply.
|
|
@@ -89,15 +294,15 @@ function isReply(value, id) {
|
|
|
89
294
|
* that id: a success `value` is narrowed through `result` (a value that fails the guard
|
|
90
295
|
* rejects — the zero-`as` type bridge), a failure rejects with the thread's error string.
|
|
91
296
|
* A thread that ALREADY died rejects synchronously at entry from the latched
|
|
92
|
-
* {@link NodeThread.death} — its death events fired before this dispatch existed
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* job rejects.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
297
|
+
* {@link NodeThread.death} — its death events fired before this dispatch existed and will
|
|
298
|
+
* never fire again, so waiting on the listeners below would dangle forever; the latch makes
|
|
299
|
+
* death total across every event ordering. If the thread `error`s / `exit`s mid-flight it is
|
|
300
|
+
* marked dead and the
|
|
301
|
+
* job rejects. An inbound `messageerror` also evicts and terminates the thread before
|
|
302
|
+
* rejection. On `execution.signal` abort it contains the cooperative `abort` post,
|
|
303
|
+
* evicts the thread, and observes `terminate()` settlement because CPU-bound work cannot
|
|
304
|
+
* honour the signal; the freed pool slot then gets a fresh thread. Every per-job listener
|
|
305
|
+
* (`message` / `messageerror` / `error` / `exit` / `abort`) is removed on settle.
|
|
101
306
|
*
|
|
102
307
|
* @typeParam TResult - The reply type the `result` guard narrows to
|
|
103
308
|
* @param thread - The leased thread to run the job on
|
|
@@ -107,73 +312,7 @@ function isReply(value, id) {
|
|
|
107
312
|
* @returns A promise resolving the narrowed `TResult`, or rejecting on error / abort
|
|
108
313
|
*/
|
|
109
314
|
function dispatch(thread, input, execution, result) {
|
|
110
|
-
|
|
111
|
-
const worker = thread.worker;
|
|
112
|
-
return new Promise((resolve, reject) => {
|
|
113
|
-
if (thread.death !== void 0 || !thread.alive) {
|
|
114
|
-
reject(thread.death ?? /* @__PURE__ */ new Error("worker thread is dead"));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
let settled = false;
|
|
118
|
-
let detach = () => {};
|
|
119
|
-
const settle = (action) => {
|
|
120
|
-
if (settled) return;
|
|
121
|
-
settled = true;
|
|
122
|
-
detach();
|
|
123
|
-
action();
|
|
124
|
-
};
|
|
125
|
-
const onMessage = (value) => {
|
|
126
|
-
if (!isReply(value, id)) return;
|
|
127
|
-
if (value.ok) {
|
|
128
|
-
const reply = value.value;
|
|
129
|
-
if (result(reply)) settle(() => resolve(reply));
|
|
130
|
-
else settle(() => reject(/* @__PURE__ */ new Error("reply did not satisfy result guard")));
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
const message = value.error;
|
|
134
|
-
settle(() => reject(new Error(message)));
|
|
135
|
-
};
|
|
136
|
-
const onError = (error) => {
|
|
137
|
-
thread.alive = false;
|
|
138
|
-
settle(() => reject(error));
|
|
139
|
-
};
|
|
140
|
-
const onExit = () => {
|
|
141
|
-
thread.alive = false;
|
|
142
|
-
settle(() => reject(/* @__PURE__ */ new Error("worker thread exited")));
|
|
143
|
-
};
|
|
144
|
-
const onAbort = () => {
|
|
145
|
-
worker.postMessage({
|
|
146
|
-
id,
|
|
147
|
-
command: "abort"
|
|
148
|
-
});
|
|
149
|
-
thread.alive = false;
|
|
150
|
-
worker.terminate();
|
|
151
|
-
settle(() => reject(/* @__PURE__ */ new Error("job aborted")));
|
|
152
|
-
};
|
|
153
|
-
detach = () => {
|
|
154
|
-
worker.off("message", onMessage);
|
|
155
|
-
worker.off("error", onError);
|
|
156
|
-
worker.off("exit", onExit);
|
|
157
|
-
execution.signal.removeEventListener("abort", onAbort);
|
|
158
|
-
};
|
|
159
|
-
worker.on("message", onMessage);
|
|
160
|
-
worker.on("error", onError);
|
|
161
|
-
worker.on("exit", onExit);
|
|
162
|
-
if (execution.signal.aborted) {
|
|
163
|
-
onAbort();
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
execution.signal.addEventListener("abort", onAbort, { once: true });
|
|
167
|
-
try {
|
|
168
|
-
worker.postMessage({
|
|
169
|
-
id,
|
|
170
|
-
command: "run",
|
|
171
|
-
input
|
|
172
|
-
});
|
|
173
|
-
} catch (error) {
|
|
174
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
175
|
-
}
|
|
176
|
-
});
|
|
315
|
+
return new Dispatch(thread, input, execution, result).promise;
|
|
177
316
|
}
|
|
178
317
|
//#endregion
|
|
179
318
|
//#region src/server/serve.ts
|
|
@@ -194,7 +333,10 @@ function isAbort(value) {
|
|
|
194
333
|
* run/abort protocol: a `run` message narrows its `input` through `options.input` (an
|
|
195
334
|
* invalid payload replies with an error envelope, never running the handler), then runs
|
|
196
335
|
* `options.handler(input, { signal })` and replies `{ id, ok: true, value }` on success or
|
|
197
|
-
* `{ id, ok: false, error }` on throw.
|
|
336
|
+
* `{ id, ok: false, error }` on throw. Input-guard throws use the same failure envelope. If a
|
|
337
|
+
* success value cannot be cloned, the post is retried as a clone-safe failure; if that post also
|
|
338
|
+
* fails, the parent port closes so the main side observes thread exit instead of waiting forever.
|
|
339
|
+
* Each in-flight job has its own `AbortController`,
|
|
198
340
|
* so an `abort` message for that id fires the handler's `signal` (cooperative — the main
|
|
199
341
|
* side ALSO terminates the thread, so a handler that ignores its signal is still stopped).
|
|
200
342
|
* Every inbound message is narrowed with the inlined guards — no `as`. On the main thread
|
|
@@ -207,7 +349,7 @@ function isAbort(value) {
|
|
|
207
349
|
* @example
|
|
208
350
|
* ```ts
|
|
209
351
|
* // double.ts — a worker script
|
|
210
|
-
* import { serveWorker } from '@
|
|
352
|
+
* import { serveWorker } from '@orkestrel/worker/server'
|
|
211
353
|
*
|
|
212
354
|
* serveWorker<number, number>({
|
|
213
355
|
* input: (value): value is number => typeof value === 'number',
|
|
@@ -218,6 +360,8 @@ function isAbort(value) {
|
|
|
218
360
|
function serveWorker(options) {
|
|
219
361
|
const port = parentPort;
|
|
220
362
|
if (port === null) return;
|
|
363
|
+
const input = options.input;
|
|
364
|
+
const handler = options.handler;
|
|
221
365
|
const controllers = /* @__PURE__ */ new Map();
|
|
222
366
|
port.on("message", (raw) => {
|
|
223
367
|
if (isAbort(raw)) {
|
|
@@ -226,35 +370,99 @@ function serveWorker(options) {
|
|
|
226
370
|
}
|
|
227
371
|
if (!isRun(raw)) return;
|
|
228
372
|
const id = raw.id;
|
|
229
|
-
if (!options.input(raw.input)) {
|
|
230
|
-
port.postMessage({
|
|
231
|
-
id,
|
|
232
|
-
ok: false,
|
|
233
|
-
error: "input did not satisfy input guard"
|
|
234
|
-
});
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
const input = raw.input;
|
|
238
373
|
const controller = new AbortController();
|
|
239
374
|
controllers.set(id, controller);
|
|
240
|
-
Promise.resolve().then(() =>
|
|
375
|
+
Promise.resolve().then(() => {
|
|
376
|
+
if (!input(raw.input)) throw new Error("input did not satisfy input guard");
|
|
377
|
+
const value = raw.input;
|
|
378
|
+
return handler(value, { signal: controller.signal });
|
|
379
|
+
}).then((value) => {
|
|
241
380
|
controllers.delete(id);
|
|
242
381
|
port.postMessage({
|
|
243
382
|
id,
|
|
244
383
|
ok: true,
|
|
245
384
|
value
|
|
246
385
|
});
|
|
247
|
-
}
|
|
386
|
+
}).catch((error) => {
|
|
248
387
|
controllers.delete(id);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
388
|
+
let message = "worker operation failed";
|
|
389
|
+
try {
|
|
390
|
+
message = error instanceof Error ? error.message : String(error);
|
|
391
|
+
} catch {}
|
|
392
|
+
try {
|
|
393
|
+
port.postMessage({
|
|
394
|
+
id,
|
|
395
|
+
ok: false,
|
|
396
|
+
error: message
|
|
397
|
+
});
|
|
398
|
+
} catch {
|
|
399
|
+
try {
|
|
400
|
+
port.close();
|
|
401
|
+
} catch {}
|
|
402
|
+
}
|
|
254
403
|
});
|
|
255
404
|
});
|
|
256
405
|
}
|
|
257
406
|
//#endregion
|
|
407
|
+
//#region src/server/NodeWorker.ts
|
|
408
|
+
/**
|
|
409
|
+
* Internal composition entity backing {@link createNodeWorker}.
|
|
410
|
+
*
|
|
411
|
+
* @remarks
|
|
412
|
+
* Supplies bound Pool and Queue operations without nested function assignments. The resulting
|
|
413
|
+
* public entity remains the plain core {@link WorkerInterface}.
|
|
414
|
+
*/
|
|
415
|
+
var NodeWorker = class {
|
|
416
|
+
#script;
|
|
417
|
+
#input;
|
|
418
|
+
#result;
|
|
419
|
+
#workerData;
|
|
420
|
+
#concurrency;
|
|
421
|
+
#retries;
|
|
422
|
+
#timeout;
|
|
423
|
+
#store;
|
|
424
|
+
constructor(options) {
|
|
425
|
+
this.#script = options.script;
|
|
426
|
+
this.#input = options.input;
|
|
427
|
+
this.#result = options.result;
|
|
428
|
+
this.#workerData = options.workerData;
|
|
429
|
+
this.#concurrency = options.concurrency;
|
|
430
|
+
this.#retries = options.retries;
|
|
431
|
+
this.#timeout = options.timeout;
|
|
432
|
+
this.#store = options.store;
|
|
433
|
+
}
|
|
434
|
+
build() {
|
|
435
|
+
return createWorker({
|
|
436
|
+
pool: {
|
|
437
|
+
create: this.#create.bind(this),
|
|
438
|
+
destroy: this.#destroy.bind(this),
|
|
439
|
+
validate: this.#validate.bind(this),
|
|
440
|
+
...this.#concurrency !== void 0 ? { max: this.#concurrency } : {}
|
|
441
|
+
},
|
|
442
|
+
handler: this.#handle.bind(this),
|
|
443
|
+
...this.#concurrency !== void 0 ? { concurrency: this.#concurrency } : {},
|
|
444
|
+
...this.#retries !== void 0 ? { retries: this.#retries } : {},
|
|
445
|
+
...this.#timeout !== void 0 ? { timeout: this.#timeout } : {},
|
|
446
|
+
...this.#store !== void 0 ? { store: this.#store } : {}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
#create() {
|
|
450
|
+
return spawnThread(this.#script, this.#workerData);
|
|
451
|
+
}
|
|
452
|
+
async #destroy(thread) {
|
|
453
|
+
await thread.worker.terminate();
|
|
454
|
+
}
|
|
455
|
+
#validate(thread) {
|
|
456
|
+
return thread.alive && thread.worker.threadId > 0;
|
|
457
|
+
}
|
|
458
|
+
#handle(input, thread, execution) {
|
|
459
|
+
const outcome = attempt(() => this.#input(input));
|
|
460
|
+
if (!outcome.success) return Promise.reject(outcome.error);
|
|
461
|
+
if (!outcome.value) return Promise.reject(/* @__PURE__ */ new Error("input did not satisfy input guard"));
|
|
462
|
+
return dispatch(thread, input, execution, this.#result);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
//#endregion
|
|
258
466
|
//#region src/server/factories.ts
|
|
259
467
|
/**
|
|
260
468
|
* Create a persistent JSON-file {@link QueueStoreInterface} — the core
|
|
@@ -276,8 +484,8 @@ function serveWorker(options) {
|
|
|
276
484
|
*
|
|
277
485
|
* @example
|
|
278
486
|
* ```ts
|
|
279
|
-
* import { stringShape } from '@
|
|
280
|
-
* import { createJSONQueueStore } from '@
|
|
487
|
+
* import { stringShape } from '@orkestrel/contract'
|
|
488
|
+
* import { createJSONQueueStore } from '@orkestrel/worker/server'
|
|
281
489
|
*
|
|
282
490
|
* const store = createJSONQueueStore('data/queue.json', stringShape())
|
|
283
491
|
* await store.save({ id: 'job-1', input: 'https://example.com', attempts: 0 })
|
|
@@ -317,7 +525,7 @@ function createJSONQueueStore(path, input) {
|
|
|
317
525
|
*
|
|
318
526
|
* @example
|
|
319
527
|
* ```ts
|
|
320
|
-
* import { createNodeWorker } from '@
|
|
528
|
+
* import { createNodeWorker } from '@orkestrel/worker/server'
|
|
321
529
|
*
|
|
322
530
|
* const worker = createNodeWorker({
|
|
323
531
|
* script: new URL('./double.js', import.meta.url),
|
|
@@ -327,26 +535,11 @@ function createJSONQueueStore(path, input) {
|
|
|
327
535
|
* })
|
|
328
536
|
*
|
|
329
537
|
* const doubled = await worker.enqueue(21) // 42, computed on a worker thread
|
|
330
|
-
* worker.destroy() // terminates every thread
|
|
538
|
+
* await worker.destroy() // terminates every thread
|
|
331
539
|
* ```
|
|
332
540
|
*/
|
|
333
541
|
function createNodeWorker(options) {
|
|
334
|
-
return
|
|
335
|
-
pool: {
|
|
336
|
-
create: () => spawnThread(options.script, options.workerData),
|
|
337
|
-
destroy: (thread) => thread.worker.terminate().then(() => {}),
|
|
338
|
-
validate: (thread) => thread.alive && thread.worker.threadId > 0,
|
|
339
|
-
max: options.concurrency
|
|
340
|
-
},
|
|
341
|
-
handler: (input, thread, execution) => {
|
|
342
|
-
if (!options.input(input)) return Promise.reject(/* @__PURE__ */ new Error("input did not satisfy input guard"));
|
|
343
|
-
return dispatch(thread, input, execution, options.result);
|
|
344
|
-
},
|
|
345
|
-
concurrency: options.concurrency,
|
|
346
|
-
retries: options.retries,
|
|
347
|
-
timeout: options.timeout,
|
|
348
|
-
store: options.store
|
|
349
|
-
});
|
|
542
|
+
return new NodeWorker(options).build();
|
|
350
543
|
}
|
|
351
544
|
//#endregion
|
|
352
545
|
export { createJSONQueueStore, createNodeWorker, dispatch, isReply, serveWorker, spawnThread };
|