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