@genroc/eval-node 0.0.0-edge.d1df786 → 0.0.0-edge.d46a8ebf

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/dist/worker.js CHANGED
@@ -1,31 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // The queue worker: claims parked `external` script tasks from genroc, evaluates each in its
3
- // own realm, and answers. This is the whole genroc-facing half — eval.ts and realm.ts know
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
  import { readFileSync } from "node:fs";
8
- import { evaluate } from "./eval.js";
5
+ import { Cancelled, evaluate } from "./eval.js";
9
6
  const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
10
7
  const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
11
- // The credential, when the server runs with --auth token. A worker needs exactly the `worker`
12
- // permission — the four queue verbs plus GET /api/objects — so mint it scoped rather than
13
- // handing a worker an admin token: this is the credential most likely to sit on a machine you
14
- // trust least. specs/api-auth.md §5.
15
- //
16
- // Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
17
- // credentials ("Request cannot be constructed from a URL that includes credentials"), so the
18
- // basic-auth-in-the-URL trick that works for genctl is not available here.
19
- // GENROC_TOKEN_FILE is the mounted-secret shape: a credential in a file rather than an
20
- // environment variable, so it stays out of `docker inspect` and out of the process environment
21
- // any child inherits. The inline variable wins when both are set.
8
+ // Needs only the `worker` permission (specs/api-auth.md §5): this credential sits on the machine
9
+ // you trust least. A header, because Node's fetch refuses credentials in a URL. The file form
10
+ // keeps it out of `docker inspect` and child environments; the inline variable wins.
22
11
  const TOKEN = process.env.GENROC_TOKEN ??
23
12
  (process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
24
13
  const authHeaders = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
25
- // Concurrency is the worker's to set, and that is the point of pulling: under the old fetch
26
- // shape genroc decided how many scripts ran at once (--max-concurrent, default 200) and the
27
- // evaluator accepted every one of them. Here it claims what it can run and no more, so a
28
- // backlog is a queue rather than 200 threads fighting over a core.
14
+ // The worker claims only what it can run, so a backlog is a queue rather than threads fighting
15
+ // over a core.
29
16
  const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
30
17
  const POLL_MS = Number(process.env.POLL_MS ?? 250);
31
18
  // The visibility timeout. Short, and renewed while work is in flight: a worker that dies
@@ -34,17 +21,15 @@ const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
34
21
  const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
35
22
  const PROCESS_FILTER = process.env.PROCESS ?? "";
36
23
  const TASK_FILTER = process.env.TASK ?? "";
37
- // Values too large to ship inline are listed rather than carried, and a bundle is exactly that:
38
- // one object shared by every instance of a definition version, fetched once instead of copied
39
- // into each task. A ref is a content hash, so it is immutable — the cache never invalidates.
24
+ // Large values arrive as refs; a bundle is one object per definition version, fetched once.
25
+ // Refs are content hashes, so the cache never invalidates.
40
26
  const objectCache = new Map();
41
27
  async function fetchObject(ref) {
42
28
  const cached = objectCache.get(ref);
43
29
  if (cached !== undefined)
44
30
  return cached;
45
- // Left to throw on purpose: this runs while a task is IN FLIGHT, and a task whose input
46
- // cannot be fetched must fail rather than silently run against a missing value. The caller
47
- // releases the claim, so the task returns to the queue.
31
+ // Left to throw: a task whose input cannot be fetched must not run against a missing value.
32
+ // The caller releases the claim.
48
33
  const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
49
34
  if (!res.ok)
50
35
  throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
@@ -62,15 +47,15 @@ async function fetchObject(ref) {
62
47
  /** Put each listed value back where its path says it belongs. Paths are arrays of keys, so this
63
48
  * needs no parser: the whole reason they are not JSON Pointer strings. */
64
49
  async function resolveObjects(job) {
65
- let input = job.input;
50
+ let input = job.external_input;
66
51
  for (const e of job.objects ?? []) {
67
52
  const value = await fetchObject(e.ref);
68
53
  if (e.path.length === 0) {
69
54
  input = value;
70
55
  continue;
71
56
  }
72
- // The path is rooted at the entry and starts with "input", which is the value being rebuilt.
73
- const rest = e.path[0] === "input" ? e.path.slice(1) : e.path;
57
+ // The path is rooted at the entry and starts with "external_input", the value being rebuilt.
58
+ const rest = e.path[0] === "external_input" ? e.path.slice(1) : e.path;
74
59
  if (rest.length === 0) {
75
60
  input = value;
76
61
  continue;
@@ -84,10 +69,8 @@ async function resolveObjects(job) {
84
69
  return input;
85
70
  }
86
71
  async function call(path, body) {
87
- // A network error is a REPLY, not a throw. A worker outlives the server it polls — a
88
- // restart, a rolling deploy, a container coming up before genroc is listening — and an
89
- // unhandled rejection here kills it for a condition the next poll would clear. Status 0
90
- // says "never reached the server", which is distinct from anything genroc answers.
72
+ // A network error is a REPLY (status 0, "never reached the server"), not a throw: a worker
73
+ // outlives restarts and deploys, and an unhandled rejection would kill it.
91
74
  let res;
92
75
  try {
93
76
  res = await fetch(SERVER + path, {
@@ -109,9 +92,8 @@ async function call(path, body) {
109
92
  }
110
93
  return { ok: res.ok, status: res.status, data };
111
94
  }
112
- /** The task input IS an EvalRequest: `code` required, `input` and `timeout_ms` optional. A task
113
- * whose input is not that shape is the definition's fault, not the script's, and is reported
114
- * as a compile_error — the nearest permanent kind, since no retry can fix the definition. */
95
+ /** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
96
+ * the nearest permanent kind, since no retry fixes the definition. */
115
97
  function asEvalRequest(input) {
116
98
  if (typeof input !== "object" || input === null)
117
99
  return "the task input is not an object";
@@ -126,10 +108,8 @@ function asEvalRequest(input) {
126
108
  }
127
109
  const inFlight = new Map();
128
110
  let running = true;
129
- // Whether the last claim reached genroc. A worker polls several times a second, so an
130
- // unreachable server would otherwise emit a line per poll — thousands during a restart, which
131
- // buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
132
- // back. Silence in between is the report that nothing changed.
111
+ // Announce reachability TRANSITIONS only: polling several times a second would otherwise emit
112
+ // thousands of lines during a restart.
133
113
  let serverReachable = true;
134
114
  async function claim(n) {
135
115
  const { ok, status, data } = await call("/api/external-tasks/claim", {
@@ -140,18 +120,15 @@ async function claim(n) {
140
120
  ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
141
121
  });
142
122
  if (!ok) {
143
- // A credential problem is not transient, and polling through it looks like a healthy
144
- // worker that never picks anything up — the worst shape for an operator to debug. Exit
145
- // instead, so a supervisor restarts it and the failure is visible where it happened.
123
+ // A credential problem is not transient, and polling through it looks like a healthy idle
124
+ // worker. Exit, so a supervisor restarts it visibly.
146
125
  if (status === 401 || status === 403) {
147
126
  console.error(`claim rejected (${status}): ${JSON.stringify(data)}\n` +
148
127
  `The server requires authentication. Set GENROC_TOKEN to a token with the 'worker' ` +
149
128
  `permission — mint one with: genctl token create --perms worker --label evaluator -q`);
150
129
  process.exit(1);
151
130
  }
152
- // status 0 is "never reached the server" (see call): a restart, a rolling deploy, a
153
- // network blip. Not an error to act on — the next poll clears it — so it is reported once
154
- // and then waited out.
131
+ // status 0 (see call): a restart or blip the next poll clears, so it is reported once.
155
132
  if (status === 0) {
156
133
  if (serverReachable) {
157
134
  serverReachable = false;
@@ -174,10 +151,8 @@ async function release(token) {
174
151
  if (!ok)
175
152
  console.error(`release failed: ${JSON.stringify(data)}`);
176
153
  }
177
- /** answer submits the outcome. A refusal is NOT retried with a different one: the definition
178
- * declared a contract this worker does not satisfy (an undeclared code, a payload that does
179
- * not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
180
- * the task returns to the queue and an operator sees it waiting rather than silently gone. */
154
+ /** A refused outcome is not retried with another: the worker does not satisfy the declared
155
+ * contract. Released, so an operator sees the task waiting rather than gone. */
181
156
  async function answer(token, outcome) {
182
157
  const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
183
158
  if (ok)
@@ -185,7 +160,7 @@ async function answer(token, outcome) {
185
160
  console.error(`genroc refused the outcome for ${token}: ${JSON.stringify(data)}`);
186
161
  await release(token);
187
162
  }
188
- async function run(job) {
163
+ async function run(job, signal) {
189
164
  let resolved;
190
165
  try {
191
166
  resolved = await resolveObjects(job);
@@ -206,13 +181,18 @@ async function run(job) {
206
181
  }
207
182
  let result;
208
183
  try {
209
- result = await evaluate(req);
184
+ result = await evaluate(req, signal);
210
185
  }
211
186
  catch (err) {
212
- // The RUNNER faulted, not the script — the one class where a retry can help. There is no
213
- // error code for it on purpose: releasing the claim is how a queue spells "retryable", and
214
- // it puts the task in front of a different worker instead of burning the definition's
215
- // on_error budget on this one's bad day.
187
+ // Cancelled is not a fault. Still released: the row is terminal so nothing re-claims it,
188
+ // but the release stops it waiting out a lease nobody serves.
189
+ if (err instanceof Cancelled) {
190
+ console.log(`cancelled: ${job.token}`);
191
+ await release(job.token);
192
+ return;
193
+ }
194
+ // The RUNNER faulted, the one retryable class. Releasing is how a queue spells "retryable",
195
+ // and it reaches another worker without spending the definition's on_error budget.
216
196
  console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
217
197
  await release(job.token);
218
198
  return;
@@ -245,11 +225,18 @@ async function renewLoop() {
245
225
  tokens,
246
226
  lease_ms: LEASE_MS,
247
227
  });
248
- // A short count means a claim lapsed and was taken over. Nothing to do about it — the work
249
- // continues and its answer will be refused — but say so, because it is the signal that
250
- // LEASE_MS is too short for what these scripts actually take.
251
- if (ok && data?.renewed < tokens.length) {
252
- console.error(`renewed ${data.renewed}/${tokens.length} claims; a lease lapsed under load`);
228
+ if (!ok)
229
+ continue;
230
+ // Abort first rather than let a stopped script burn a core; the release rides run()'s
231
+ // catch, so nothing is released twice.
232
+ for (const token of (data?.cancelled ?? [])) {
233
+ inFlight.get(token)?.abort.abort();
234
+ }
235
+ // Lost: another worker holds it now and our answer will be refused; logged as the sign that
236
+ // LEASE_MS is too short. NOT released: that would bump the epoch out from under the holder.
237
+ const lost = (data?.lost ?? []);
238
+ if (lost.length) {
239
+ console.error(`lost ${lost.length}/${tokens.length} claims; a lease lapsed under load`);
253
240
  }
254
241
  }
255
242
  }
@@ -258,8 +245,9 @@ async function pollLoop() {
258
245
  const free = CONCURRENCY - inFlight.size;
259
246
  const jobs = free > 0 ? await claim(free) : [];
260
247
  for (const job of jobs) {
261
- inFlight.set(job.token, job);
262
- void run(job).finally(() => inFlight.delete(job.token));
248
+ const abort = new AbortController();
249
+ inFlight.set(job.token, { job, abort });
250
+ void run(job, abort.signal).finally(() => inFlight.delete(job.token));
263
251
  }
264
252
  // Only idle when there was nothing to take: a full queue should be drained at the speed the
265
253
  // realms allow, not at the poll interval.
package/eval.ts CHANGED
@@ -1,22 +1,18 @@
1
- // Evaluation core: run a code string in its OWN realm and classify every outcome into one of
2
- // the failure kinds in README.md. Nothing here knows about genroc — worker.ts is the only
3
- // thing that talks to the queue — so this stays testable and the containment stays swappable.
4
- //
5
- // The containment is a Worker per execution (realm.ts). It is what makes the budget real:
6
- // a synchronous busy loop never yields, so no in-process timer can interrupt it, and only a
7
- // thread the host can kill bounds it.
1
+ // Imports a script module in its own realm (a Worker per execution, realm.ts), calls its default
2
+ // export, and classifies every outcome (README.md). Only a killable thread bounds a synchronous
3
+ // busy loop, which is what makes the budget real. Knows nothing of genroc; worker.ts does.
8
4
 
9
5
  import { Worker } from "node:worker_threads";
10
6
 
11
7
  export type EvalRequest = {
8
+ /** An ES module whose default export is the function to run; `input` is its argument. */
12
9
  code: string;
13
10
  input?: unknown;
14
11
  timeout_ms?: number;
15
12
  };
16
13
 
17
- /** Every kind here is PERMANENT: a retry re-runs the same code on the same input and fails
18
- * identically. The retryable class has no kind because it is not an outcome — a runner that
19
- * faults releases its claim and lets another worker take the task. */
14
+ /** Every kind is PERMANENT: a retry fails identically. A faulting runner is not an outcome; it
15
+ * releases its claim. */
20
16
  export type FailureKind = "compile_error" | "threw" | "timeout" | "nonserializable" | "exited";
21
17
 
22
18
  export type EvalFailure = {
@@ -26,9 +22,8 @@ export type EvalFailure = {
26
22
  stack?: string;
27
23
  };
28
24
 
29
- /** `body` is JSON TEXT, not a value: serialising in the realm is what makes a nonserializable
30
- * return a script fault rather than a 500 thrown out of the response path. It is also what
31
- * crosses the worker boundary — structured clone would refuse a different set of values. */
25
+ /** JSON TEXT serialised in the realm, so a nonserializable return is a script fault, not a 500,
26
+ * and structured clone (which refuses a different set of values) never sees the value. */
32
27
  export type EvalResult =
33
28
  | { ok: true; body: string }
34
29
  | { ok: false; failure: EvalFailure };
@@ -45,9 +40,8 @@ const REALM_URL = new URL(
45
40
  import.meta.url,
46
41
  );
47
42
 
48
- /** Thrown, not returned: a realm that fails to start is the RUNNER faulting, which worker.ts
49
- * answers by releasing the claim rather than by reporting an outcome. A script fault is a
50
- * return value. */
43
+ /** Thrown, not returned: a realm that fails to start is the runner faulting, which worker.ts
44
+ * answers by releasing the claim. */
51
45
  class RealmFault extends Error {
52
46
  constructor(message: string) {
53
47
  super(message);
@@ -55,24 +49,41 @@ class RealmFault extends Error {
55
49
  }
56
50
  }
57
51
 
58
- export async function evaluate(req: EvalRequest): Promise<EvalResult> {
52
+ /** Thrown when `signal` aborts: the answer is no longer wanted, so worker.ts releases rather
53
+ * than answers. Not a RealmFault: nothing is wrong. */
54
+ export class Cancelled extends Error {
55
+ constructor() {
56
+ super("cancelled");
57
+ this.name = "Cancelled";
58
+ }
59
+ }
60
+
61
+ export async function evaluate(req: EvalRequest, signal?: AbortSignal): Promise<EvalResult> {
59
62
  const budget = typeof req.timeout_ms === "number" ? req.timeout_ms : DEFAULT_TIMEOUT_MS;
60
63
 
61
64
  const worker = new Worker(REALM_URL);
62
65
  let timer: ReturnType<typeof setTimeout> | undefined;
66
+ let onAbort: (() => void) | undefined;
63
67
  try {
64
68
  return await new Promise<EvalResult>((resolve, reject) => {
65
69
  timer = setTimeout(() => resolve(timedOut(budget)), budget);
70
+ // The abort is wired to the same promise as the budget, so both settle through the one
71
+ // finally below -- which is what guarantees the thread is gone before either returns.
72
+ if (signal) {
73
+ if (signal.aborted) reject(new Cancelled());
74
+ onAbort = () => reject(new Cancelled());
75
+ signal.addEventListener("abort", onAbort, { once: true });
76
+ }
66
77
  worker.once("message", (reply: WorkerReply) => resolve(reply));
67
- // A script may end its own realm (`process.exit()`), which is not a throw and would
68
- // otherwise present as a hang until the budget expired. Our own terminate() raises
69
- // this too, by which time the promise has settled and the first result stands.
78
+ // A script may `process.exit()`, which would otherwise look like a hang until the budget.
79
+ // Our own terminate() fires this too, after the promise has settled.
70
80
  worker.once("exit", (code: number) => resolve(exited(code)));
71
81
  worker.once("error", (err: Error) => reject(new RealmFault(errorText(err))));
72
82
  worker.postMessage({ code: req.code, input: req.input } satisfies WorkerRequest);
73
83
  });
74
84
  } finally {
75
85
  clearTimeout(timer);
86
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
76
87
  // Awaited, and the whole point: on the timeout path a thread is still burning a core, and
77
88
  // resolving before it is gone would report an evaluation the machine is still running.
78
89
  await worker.terminate();