@genroc/eval-node 0.0.0-edge.00cdd64d
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/LICENSE +202 -0
- package/README.md +145 -0
- package/dist/eval.js +77 -0
- package/dist/import.js +361 -0
- package/dist/realm.js +108 -0
- package/dist/worker.js +286 -0
- package/eval.ts +114 -0
- package/import.ts +472 -0
- package/package.json +42 -0
- package/realm.ts +111 -0
- package/tsconfig.json +23 -0
- package/worker.ts +313 -0
package/worker.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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).
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { Cancelled, evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
|
|
7
|
+
|
|
8
|
+
const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
|
|
9
|
+
const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
|
|
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.
|
|
13
|
+
const TOKEN =
|
|
14
|
+
process.env.GENROC_TOKEN ??
|
|
15
|
+
(process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
|
|
16
|
+
const authHeaders: Record<string, string> = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
|
|
17
|
+
// The worker claims only what it can run, so a backlog is a queue rather than threads fighting
|
|
18
|
+
// over a core.
|
|
19
|
+
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
|
|
20
|
+
const POLL_MS = Number(process.env.POLL_MS ?? 250);
|
|
21
|
+
// The visibility timeout. Short, and renewed while work is in flight: a worker that dies
|
|
22
|
+
// should return its task quickly rather than holding it for the whole budget.
|
|
23
|
+
const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
|
|
24
|
+
const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
|
|
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";
|
|
32
|
+
|
|
33
|
+
type ObjectEntry = { path: (string | number)[]; ref: string; size: number };
|
|
34
|
+
|
|
35
|
+
type QueueTask = {
|
|
36
|
+
token: string;
|
|
37
|
+
process: string;
|
|
38
|
+
task: string;
|
|
39
|
+
external_input: unknown;
|
|
40
|
+
objects?: ObjectEntry[];
|
|
41
|
+
raises?: Record<string, unknown>;
|
|
42
|
+
deadline_in_ms?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
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.
|
|
47
|
+
const objectCache = new Map<string, unknown>();
|
|
48
|
+
|
|
49
|
+
async function fetchObject(ref: string): Promise<unknown> {
|
|
50
|
+
const cached = objectCache.get(ref);
|
|
51
|
+
if (cached !== undefined) return cached;
|
|
52
|
+
// Left to throw: a task whose input cannot be fetched must not run against a missing value.
|
|
53
|
+
// The caller releases the claim.
|
|
54
|
+
const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
|
|
55
|
+
if (!res.ok) throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
|
|
56
|
+
const { data } = (await res.json()) as { data: string };
|
|
57
|
+
let value: unknown;
|
|
58
|
+
try {
|
|
59
|
+
value = JSON.parse(data);
|
|
60
|
+
} catch {
|
|
61
|
+
value = data;
|
|
62
|
+
}
|
|
63
|
+
objectCache.set(ref, value);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Put each listed value back where its path says it belongs. Paths are arrays of keys, so this
|
|
68
|
+
* needs no parser: the whole reason they are not JSON Pointer strings. */
|
|
69
|
+
async function resolveObjects(job: QueueTask): Promise<unknown> {
|
|
70
|
+
let input: any = job.external_input;
|
|
71
|
+
for (const e of job.objects ?? []) {
|
|
72
|
+
const value = await fetchObject(e.ref);
|
|
73
|
+
if (e.path.length === 0) {
|
|
74
|
+
input = value;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
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;
|
|
79
|
+
if (rest.length === 0) {
|
|
80
|
+
input = value;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
let cur: any = input;
|
|
84
|
+
for (let i = 0; i < rest.length - 1; i++) cur = cur?.[rest[i]!];
|
|
85
|
+
if (cur) cur[rest[rest.length - 1]!] = value;
|
|
86
|
+
}
|
|
87
|
+
return input;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function call(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: any }> {
|
|
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.
|
|
93
|
+
let res: Response;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(SERVER + path, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "content-type": "application/json", ...authHeaders },
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, status: 0, data: { error: `${SERVER} unreachable: ${(err as Error).message}` } };
|
|
102
|
+
}
|
|
103
|
+
const text = await res.text();
|
|
104
|
+
let data: any = null;
|
|
105
|
+
try {
|
|
106
|
+
data = text ? JSON.parse(text) : null;
|
|
107
|
+
} catch {
|
|
108
|
+
data = { error: text };
|
|
109
|
+
}
|
|
110
|
+
return { ok: res.ok, status: res.status, data };
|
|
111
|
+
}
|
|
112
|
+
|
|
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 {
|
|
116
|
+
if (typeof input !== "object" || input === null) return "the task input is not an object";
|
|
117
|
+
const r = input as Record<string, unknown>;
|
|
118
|
+
if (typeof r.code !== "string") return "the task input has no `code` string";
|
|
119
|
+
return { code: r.code, input: r.input, timeout_ms: timeoutMs };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** genroc cannot call us, so a cancellation arrives on our own heartbeat and reaches the
|
|
123
|
+
* evaluation through the controller. */
|
|
124
|
+
type Running = { job: QueueTask; abort: AbortController };
|
|
125
|
+
|
|
126
|
+
const inFlight = new Map<string, Running>();
|
|
127
|
+
let running = true;
|
|
128
|
+
|
|
129
|
+
// Announce reachability TRANSITIONS only: polling several times a second would otherwise emit
|
|
130
|
+
// thousands of lines during a restart.
|
|
131
|
+
let serverReachable = true;
|
|
132
|
+
|
|
133
|
+
async function claim(n: number): Promise<QueueTask[]> {
|
|
134
|
+
const { ok, status, data } = await call("/api/external-tasks/claim", {
|
|
135
|
+
worker_id: WORKER_ID,
|
|
136
|
+
limit: n,
|
|
137
|
+
lease_ms: LEASE_MS,
|
|
138
|
+
...(PROCESS_FILTER ? { process: PROCESS_FILTER } : {}),
|
|
139
|
+
...(TASK_FILTER ? { task: TASK_FILTER } : {}),
|
|
140
|
+
});
|
|
141
|
+
if (!ok) {
|
|
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.
|
|
144
|
+
if (status === 401 || status === 403) {
|
|
145
|
+
console.error(
|
|
146
|
+
`claim rejected (${status}): ${JSON.stringify(data)}\n` +
|
|
147
|
+
`The server requires authentication. Set GENROC_TOKEN to a token with the 'worker' ` +
|
|
148
|
+
`permission — mint one with: genctl token create --perms worker --label evaluator -q`,
|
|
149
|
+
);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
// status 0 (see call): a restart or blip the next poll clears, so it is reported once.
|
|
153
|
+
if (status === 0) {
|
|
154
|
+
if (serverReachable) {
|
|
155
|
+
serverReachable = false;
|
|
156
|
+
console.error(
|
|
157
|
+
`genroc at ${SERVER} is unreachable — ${(data as { error?: string })?.error ?? ""}. ` +
|
|
158
|
+
`Still polling every ${POLL_MS}ms; work resumes when it comes back.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
console.error(`claim failed: ${JSON.stringify(data)}`);
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
if (!serverReachable) {
|
|
167
|
+
serverReachable = true;
|
|
168
|
+
console.error(`genroc at ${SERVER} is reachable again — resuming.`);
|
|
169
|
+
}
|
|
170
|
+
return (data?.items ?? []) as QueueTask[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function release(token: string): Promise<void> {
|
|
174
|
+
const { ok, data } = await call("/api/external-tasks/release", { token });
|
|
175
|
+
if (!ok) console.error(`release failed: ${JSON.stringify(data)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
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. */
|
|
180
|
+
async function answer(token: string, outcome: Record<string, unknown>): Promise<void> {
|
|
181
|
+
const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
|
|
182
|
+
if (ok) return;
|
|
183
|
+
console.error(`genroc refused the outcome for ${token}: ${JSON.stringify(data)}`);
|
|
184
|
+
await release(token);
|
|
185
|
+
}
|
|
186
|
+
|
|
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;
|
|
191
|
+
let resolved: unknown;
|
|
192
|
+
try {
|
|
193
|
+
resolved = await resolveObjects(job);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// The values are there or they are not; this is the runner failing to read them, not the
|
|
196
|
+
// script failing, so hand the task back for someone else rather than reporting an outcome.
|
|
197
|
+
console.error(`resolving objects for ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
|
|
198
|
+
await release(job.token);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
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);
|
|
211
|
+
if (typeof req === "string") {
|
|
212
|
+
await answer(job.token, {
|
|
213
|
+
error: { code: "compile_error", message: req, data: { name: "BadTaskInput" } },
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
result = await evaluate(req, signal);
|
|
221
|
+
} catch (err) {
|
|
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.
|
|
224
|
+
if (err instanceof Cancelled) {
|
|
225
|
+
console.log(`cancelled: ${job.token}`);
|
|
226
|
+
await release(job.token);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
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.
|
|
231
|
+
console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
await release(job.token);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (result.ok) {
|
|
237
|
+
// `body` is JSON text produced inside the realm; an empty body is a script that returned
|
|
238
|
+
// nothing, which genroc reads as null.
|
|
239
|
+
await answer(job.token, { result: result.body === "" ? null : JSON.parse(result.body) });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const f = result.failure;
|
|
243
|
+
await answer(job.token, {
|
|
244
|
+
error: {
|
|
245
|
+
// The failure KIND is the code, so an on_error rule branches on what went wrong without
|
|
246
|
+
// reading a payload. Every kind is permanent; see eval.ts.
|
|
247
|
+
code: f.kind satisfies FailureKind,
|
|
248
|
+
message: f.message,
|
|
249
|
+
data: { name: f.name, ...(f.stack ? { stack: f.stack } : {}) },
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function renewLoop(): Promise<void> {
|
|
255
|
+
while (running) {
|
|
256
|
+
await new Promise((r) => setTimeout(r, RENEW_MS));
|
|
257
|
+
const tokens = [...inFlight.keys()];
|
|
258
|
+
if (!tokens.length) continue;
|
|
259
|
+
const { ok, data } = await call("/api/external-tasks/renew", {
|
|
260
|
+
worker_id: WORKER_ID,
|
|
261
|
+
tokens,
|
|
262
|
+
lease_ms: LEASE_MS,
|
|
263
|
+
});
|
|
264
|
+
if (!ok) continue;
|
|
265
|
+
|
|
266
|
+
// Abort first rather than let a stopped script burn a core; the release rides run()'s
|
|
267
|
+
// catch, so nothing is released twice.
|
|
268
|
+
for (const token of (data?.cancelled ?? []) as string[]) {
|
|
269
|
+
inFlight.get(token)?.abort.abort();
|
|
270
|
+
}
|
|
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.
|
|
273
|
+
const lost = (data?.lost ?? []) as string[];
|
|
274
|
+
if (lost.length) {
|
|
275
|
+
console.error(`lost ${lost.length}/${tokens.length} claims; a lease lapsed under load`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function pollLoop(): Promise<void> {
|
|
281
|
+
while (running) {
|
|
282
|
+
const free = CONCURRENCY - inFlight.size;
|
|
283
|
+
const jobs = free > 0 ? await claim(free) : [];
|
|
284
|
+
for (const job of jobs) {
|
|
285
|
+
const abort = new AbortController();
|
|
286
|
+
inFlight.set(job.token, { job, abort });
|
|
287
|
+
void run(job, abort.signal).finally(() => inFlight.delete(job.token));
|
|
288
|
+
}
|
|
289
|
+
// Only idle when there was nothing to take: a full queue should be drained at the speed the
|
|
290
|
+
// realms allow, not at the poll interval.
|
|
291
|
+
if (jobs.length === 0) await new Promise((r) => setTimeout(r, POLL_MS));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function shutdown(signal: string): Promise<void> {
|
|
296
|
+
if (!running) return;
|
|
297
|
+
running = false;
|
|
298
|
+
const tokens = [...inFlight.keys()];
|
|
299
|
+
console.log(`${signal}: releasing ${tokens.length} claim(s)`);
|
|
300
|
+
// Hand work back rather than letting it sit out its lease. The evaluations still running are
|
|
301
|
+
// abandoned, which is exactly what the release says: nobody answered.
|
|
302
|
+
await Promise.all(tokens.map(release));
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
307
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
308
|
+
|
|
309
|
+
console.log(
|
|
310
|
+
`evaluator worker ${WORKER_ID} polling ${SERVER} (concurrency=${CONCURRENCY}, lease=${LEASE_MS}ms)`,
|
|
311
|
+
);
|
|
312
|
+
void renewLoop();
|
|
313
|
+
void pollLoop();
|