@genroc/eval-node 0.0.0-edge.e4b0e52 → 0.0.0-edge.e50224ac
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 +114 -306
- package/dist/eval.js +9 -17
- package/dist/import.js +64 -100
- package/dist/realm.js +12 -17
- package/dist/worker.js +55 -74
- package/eval.ts +13 -23
- package/import.ts +83 -118
- package/package.json +1 -1
- package/realm.ts +12 -17
- package/worker.ts +60 -79
package/worker.ts
CHANGED
|
@@ -1,65 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The queue worker: claims parked `external` script tasks
|
|
3
|
-
//
|
|
4
|
-
// nothing about the queue, which is what keeps the containment strategy swappable.
|
|
5
|
-
//
|
|
6
|
-
// See README.md for the contract, and specs/external-task-queue.md for the queue itself.
|
|
2
|
+
// The queue worker: claims parked `external` script tasks, evaluates each in its own realm, and
|
|
3
|
+
// answers. The only genroc-facing half (README.md; specs/external-task-queue.md).
|
|
7
4
|
|
|
8
5
|
import { readFileSync } from "node:fs";
|
|
9
6
|
import { Cancelled, evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
|
|
10
7
|
|
|
11
8
|
const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
|
|
12
9
|
const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// trust least. specs/api-auth.md §5.
|
|
17
|
-
//
|
|
18
|
-
// Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
|
|
19
|
-
// credentials ("Request cannot be constructed from a URL that includes credentials"), so the
|
|
20
|
-
// basic-auth-in-the-URL trick that works for genctl is not available here.
|
|
21
|
-
// GENROC_TOKEN_FILE is the mounted-secret shape: a credential in a file rather than an
|
|
22
|
-
// environment variable, so it stays out of `docker inspect` and out of the process environment
|
|
23
|
-
// any child inherits. The inline variable wins when both are set.
|
|
10
|
+
// Needs only the `worker` permission (specs/api-auth.md §5): this credential sits on the machine
|
|
11
|
+
// you trust least. A header, because Node's fetch refuses credentials in a URL. The file form
|
|
12
|
+
// keeps it out of `docker inspect` and child environments; the inline variable wins.
|
|
24
13
|
const TOKEN =
|
|
25
14
|
process.env.GENROC_TOKEN ??
|
|
26
15
|
(process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
|
|
27
16
|
const authHeaders: Record<string, string> = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// evaluator accepted every one of them. Here it claims what it can run and no more, so a
|
|
31
|
-
// backlog is a queue rather than 200 threads fighting over a core.
|
|
17
|
+
// The worker claims only what it can run, so a backlog is a queue rather than threads fighting
|
|
18
|
+
// over a core.
|
|
32
19
|
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
|
|
33
20
|
const POLL_MS = Number(process.env.POLL_MS ?? 250);
|
|
34
21
|
// The visibility timeout. Short, and renewed while work is in flight: a worker that dies
|
|
35
22
|
// should return its task quickly rather than holding it for the whole budget.
|
|
36
23
|
const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
|
|
37
24
|
const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
|
|
38
|
-
|
|
39
|
-
|
|
25
|
+
// The answer has to land before the task's deadline fires, or a script that overran surfaces as
|
|
26
|
+
// external.timeout ("nobody answered") rather than as its own `timeout`.
|
|
27
|
+
const ANSWER_MARGIN_MS = 250;
|
|
28
|
+
// script-node's names. A renamed copy needs PROCESS set; an explicit "" drops that filter, and
|
|
29
|
+
// both "" claims every external task, other runtimes' included.
|
|
30
|
+
const PROCESS_FILTER = process.env.PROCESS ?? "script-node";
|
|
31
|
+
const TASK_FILTER = process.env.TASK ?? "eval_node";
|
|
40
32
|
|
|
41
33
|
type ObjectEntry = { path: (string | number)[]; ref: string; size: number };
|
|
42
34
|
|
|
43
35
|
type QueueTask = {
|
|
44
36
|
token: string;
|
|
45
37
|
process: string;
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
task: string;
|
|
39
|
+
external_input: unknown;
|
|
48
40
|
objects?: ObjectEntry[];
|
|
49
41
|
raises?: Record<string, unknown>;
|
|
42
|
+
deadline_in_ms?: number;
|
|
50
43
|
};
|
|
51
44
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// into each task. A ref is a content hash, so it is immutable — the cache never invalidates.
|
|
45
|
+
// Large values arrive as refs; a bundle is one object per definition version, fetched once.
|
|
46
|
+
// Refs are content hashes, so the cache never invalidates.
|
|
55
47
|
const objectCache = new Map<string, unknown>();
|
|
56
48
|
|
|
57
49
|
async function fetchObject(ref: string): Promise<unknown> {
|
|
58
50
|
const cached = objectCache.get(ref);
|
|
59
51
|
if (cached !== undefined) return cached;
|
|
60
|
-
// Left to throw
|
|
61
|
-
//
|
|
62
|
-
// releases the claim, so the task returns to the queue.
|
|
52
|
+
// Left to throw: a task whose input cannot be fetched must not run against a missing value.
|
|
53
|
+
// The caller releases the claim.
|
|
63
54
|
const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
|
|
64
55
|
if (!res.ok) throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
|
|
65
56
|
const { data } = (await res.json()) as { data: string };
|
|
@@ -76,15 +67,15 @@ async function fetchObject(ref: string): Promise<unknown> {
|
|
|
76
67
|
/** Put each listed value back where its path says it belongs. Paths are arrays of keys, so this
|
|
77
68
|
* needs no parser: the whole reason they are not JSON Pointer strings. */
|
|
78
69
|
async function resolveObjects(job: QueueTask): Promise<unknown> {
|
|
79
|
-
let input: any = job.
|
|
70
|
+
let input: any = job.external_input;
|
|
80
71
|
for (const e of job.objects ?? []) {
|
|
81
72
|
const value = await fetchObject(e.ref);
|
|
82
73
|
if (e.path.length === 0) {
|
|
83
74
|
input = value;
|
|
84
75
|
continue;
|
|
85
76
|
}
|
|
86
|
-
// The path is rooted at the entry and starts with "
|
|
87
|
-
const rest = e.path[0] === "
|
|
77
|
+
// The path is rooted at the entry and starts with "external_input", the value being rebuilt.
|
|
78
|
+
const rest = e.path[0] === "external_input" ? e.path.slice(1) : e.path;
|
|
88
79
|
if (rest.length === 0) {
|
|
89
80
|
input = value;
|
|
90
81
|
continue;
|
|
@@ -97,10 +88,8 @@ async function resolveObjects(job: QueueTask): Promise<unknown> {
|
|
|
97
88
|
}
|
|
98
89
|
|
|
99
90
|
async function call(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: any }> {
|
|
100
|
-
// A network error is a REPLY
|
|
101
|
-
//
|
|
102
|
-
// unhandled rejection here kills it for a condition the next poll would clear. Status 0
|
|
103
|
-
// says "never reached the server", which is distinct from anything genroc answers.
|
|
91
|
+
// A network error is a REPLY (status 0, "never reached the server"), not a throw: a worker
|
|
92
|
+
// outlives restarts and deploys, and an unhandled rejection would kill it.
|
|
104
93
|
let res: Response;
|
|
105
94
|
try {
|
|
106
95
|
res = await fetch(SERVER + path, {
|
|
@@ -121,32 +110,24 @@ async function call(path: string, body: unknown): Promise<{ ok: boolean; status:
|
|
|
121
110
|
return { ok: res.ok, status: res.status, data };
|
|
122
111
|
}
|
|
123
112
|
|
|
124
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
function asEvalRequest(input: unknown): EvalRequest | string {
|
|
113
|
+
/** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
|
|
114
|
+
* the nearest permanent kind, since no retry fixes the definition. */
|
|
115
|
+
function asEvalRequest(input: unknown, timeoutMs: number | undefined): EvalRequest | string {
|
|
128
116
|
if (typeof input !== "object" || input === null) return "the task input is not an object";
|
|
129
117
|
const r = input as Record<string, unknown>;
|
|
130
118
|
if (typeof r.code !== "string") return "the task input has no `code` string";
|
|
131
|
-
return {
|
|
132
|
-
code: r.code,
|
|
133
|
-
input: r.input,
|
|
134
|
-
timeout_ms: typeof r.timeout_ms === "number" ? r.timeout_ms : undefined,
|
|
135
|
-
};
|
|
119
|
+
return { code: r.code, input: r.input, timeout_ms: timeoutMs };
|
|
136
120
|
}
|
|
137
121
|
|
|
138
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
* heartbeat and has to be delivered inward from there. */
|
|
122
|
+
/** genroc cannot call us, so a cancellation arrives on our own heartbeat and reaches the
|
|
123
|
+
* evaluation through the controller. */
|
|
141
124
|
type Running = { job: QueueTask; abort: AbortController };
|
|
142
125
|
|
|
143
126
|
const inFlight = new Map<string, Running>();
|
|
144
127
|
let running = true;
|
|
145
128
|
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
|
|
149
|
-
// back. Silence in between is the report that nothing changed.
|
|
129
|
+
// Announce reachability TRANSITIONS only: polling several times a second would otherwise emit
|
|
130
|
+
// thousands of lines during a restart.
|
|
150
131
|
let serverReachable = true;
|
|
151
132
|
|
|
152
133
|
async function claim(n: number): Promise<QueueTask[]> {
|
|
@@ -158,9 +139,8 @@ async function claim(n: number): Promise<QueueTask[]> {
|
|
|
158
139
|
...(TASK_FILTER ? { task: TASK_FILTER } : {}),
|
|
159
140
|
});
|
|
160
141
|
if (!ok) {
|
|
161
|
-
// A credential problem is not transient, and polling through it looks like a healthy
|
|
162
|
-
// worker
|
|
163
|
-
// instead, so a supervisor restarts it and the failure is visible where it happened.
|
|
142
|
+
// A credential problem is not transient, and polling through it looks like a healthy idle
|
|
143
|
+
// worker. Exit, so a supervisor restarts it visibly.
|
|
164
144
|
if (status === 401 || status === 403) {
|
|
165
145
|
console.error(
|
|
166
146
|
`claim rejected (${status}): ${JSON.stringify(data)}\n` +
|
|
@@ -169,9 +149,7 @@ async function claim(n: number): Promise<QueueTask[]> {
|
|
|
169
149
|
);
|
|
170
150
|
process.exit(1);
|
|
171
151
|
}
|
|
172
|
-
// status 0
|
|
173
|
-
// network blip. Not an error to act on — the next poll clears it — so it is reported once
|
|
174
|
-
// and then waited out.
|
|
152
|
+
// status 0 (see call): a restart or blip the next poll clears, so it is reported once.
|
|
175
153
|
if (status === 0) {
|
|
176
154
|
if (serverReachable) {
|
|
177
155
|
serverReachable = false;
|
|
@@ -197,10 +175,8 @@ async function release(token: string): Promise<void> {
|
|
|
197
175
|
if (!ok) console.error(`release failed: ${JSON.stringify(data)}`);
|
|
198
176
|
}
|
|
199
177
|
|
|
200
|
-
/**
|
|
201
|
-
*
|
|
202
|
-
* not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
|
|
203
|
-
* the task returns to the queue and an operator sees it waiting rather than silently gone. */
|
|
178
|
+
/** A refused outcome is not retried with another: the worker does not satisfy the declared
|
|
179
|
+
* contract. Released, so an operator sees the task waiting rather than gone. */
|
|
204
180
|
async function answer(token: string, outcome: Record<string, unknown>): Promise<void> {
|
|
205
181
|
const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
|
|
206
182
|
if (ok) return;
|
|
@@ -209,6 +185,9 @@ async function answer(token: string, outcome: Record<string, unknown>): Promise<
|
|
|
209
185
|
}
|
|
210
186
|
|
|
211
187
|
async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
|
|
188
|
+
// The task's own timeout is the script's budget. Taken on arrival, because fetching the
|
|
189
|
+
// objects below spends the same deadline.
|
|
190
|
+
const due = job.deadline_in_ms === undefined ? undefined : performance.now() + job.deadline_in_ms;
|
|
212
191
|
let resolved: unknown;
|
|
213
192
|
try {
|
|
214
193
|
resolved = await resolveObjects(job);
|
|
@@ -219,7 +198,16 @@ async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
|
|
|
219
198
|
await release(job.token);
|
|
220
199
|
return;
|
|
221
200
|
}
|
|
222
|
-
|
|
201
|
+
let timeoutMs: number | undefined;
|
|
202
|
+
if (due !== undefined) {
|
|
203
|
+
timeoutMs = Math.floor(due - performance.now() - ANSWER_MARGIN_MS);
|
|
204
|
+
if (timeoutMs <= 0) {
|
|
205
|
+
// Too late to run anything: the deadline fires as external.timeout, which is the truth.
|
|
206
|
+
await release(job.token);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const req = asEvalRequest(resolved, timeoutMs);
|
|
223
211
|
if (typeof req === "string") {
|
|
224
212
|
await answer(job.token, {
|
|
225
213
|
error: { code: "compile_error", message: req, data: { name: "BadTaskInput" } },
|
|
@@ -231,18 +219,15 @@ async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
|
|
|
231
219
|
try {
|
|
232
220
|
result = await evaluate(req, signal);
|
|
233
221
|
} catch (err) {
|
|
234
|
-
// Cancelled is not a fault: the
|
|
235
|
-
//
|
|
236
|
-
// waiting out a lease nobody is serving.
|
|
222
|
+
// Cancelled is not a fault. Still released: the row is terminal so nothing re-claims it,
|
|
223
|
+
// but the release stops it waiting out a lease nobody serves.
|
|
237
224
|
if (err instanceof Cancelled) {
|
|
238
225
|
console.log(`cancelled: ${job.token}`);
|
|
239
226
|
await release(job.token);
|
|
240
227
|
return;
|
|
241
228
|
}
|
|
242
|
-
// The RUNNER faulted,
|
|
243
|
-
//
|
|
244
|
-
// it puts the task in front of a different worker instead of burning the definition's
|
|
245
|
-
// on_error budget on this one's bad day.
|
|
229
|
+
// The RUNNER faulted, the one retryable class. Releasing is how a queue spells "retryable",
|
|
230
|
+
// and it reaches another worker without spending the definition's on_error budget.
|
|
246
231
|
console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
|
|
247
232
|
await release(job.token);
|
|
248
233
|
return;
|
|
@@ -278,17 +263,13 @@ async function renewLoop(): Promise<void> {
|
|
|
278
263
|
});
|
|
279
264
|
if (!ok) continue;
|
|
280
265
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// keep burning a core on work an operator has already stopped. The release rides run()'s
|
|
284
|
-
// own catch, so nothing is released twice.
|
|
266
|
+
// Abort first rather than let a stopped script burn a core; the release rides run()'s
|
|
267
|
+
// catch, so nothing is released twice.
|
|
285
268
|
for (const token of (data?.cancelled ?? []) as string[]) {
|
|
286
269
|
inFlight.get(token)?.abort.abort();
|
|
287
270
|
}
|
|
288
|
-
// Lost:
|
|
289
|
-
//
|
|
290
|
-
// is too short for what these scripts actually take. Deliberately NOT released: the claim
|
|
291
|
-
// is the new holder's, and releasing would bump the epoch out from under it.
|
|
271
|
+
// Lost: another worker holds it now and our answer will be refused; logged as the sign that
|
|
272
|
+
// LEASE_MS is too short. NOT released: that would bump the epoch out from under the holder.
|
|
292
273
|
const lost = (data?.lost ?? []) as string[];
|
|
293
274
|
if (lost.length) {
|
|
294
275
|
console.error(`lost ${lost.length}/${tokens.length} claims; a lease lapsed under load`);
|