@genroc/eval-node 0.0.0-edge.e00b1c02 → 0.0.0-edge.e1372854

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 CHANGED
@@ -78,9 +78,9 @@ export default function (input: Input): Output {
78
78
  ## Running the worker
79
79
 
80
80
  ```sh
81
- docker run -e GENROC_SERVER=http://host:8448 -e TASK=eval_node ghcr.io/genroc/eval-node:preview
81
+ docker run -e GENROC_SERVER=http://host:8448 ghcr.io/genroc/eval-node:latest
82
82
  # or
83
- GENROC_SERVER=http://localhost:8448 TASK=eval_node npx genroc-eval-node
83
+ GENROC_SERVER=http://localhost:8448 npx genroc-eval-node
84
84
  ```
85
85
 
86
86
  The worker connects to genroc, not the other way round, so it only needs outbound access.
@@ -88,8 +88,8 @@ The worker connects to genroc, not the other way round, so it only needs outboun
88
88
  | env | default | |
89
89
  |---|---|---|
90
90
  | `GENROC_SERVER` | `http://localhost:8448` | the genroc server |
91
- | `GENROC_TOKEN` / `GENROC_TOKEN_FILE` | *(none)* | token, or a file holding it, when the server uses `--auth token` |
92
- | `TASK` / `PROCESS` | *(none)* | claim only this task id / process. **Without one, the worker claims every external task.** |
91
+ | `GENROC_TOKEN` / `GENROC_TOKEN_FILE` | *(none)* | token, or a file holding it, when the server uses `--auth` |
92
+ | `PROCESS` / `TASK` | `script-node` / `eval_node` | the process and task id this worker claims; the defaults are script-node's. Set `PROCESS` for a renamed copy. Neither may be empty. |
93
93
  | `CONCURRENCY` | `4` | scripts run at once |
94
94
  | `LEASE_MS` | `30000` | claim lease, renewed every third of it |
95
95
  | `POLL_MS` | `250` | idle poll interval |
@@ -109,19 +109,20 @@ match. Retrying does not help with any of them, because the same code fails the
109
109
  | code | when |
110
110
  |---|---|
111
111
  | `threw` | the script threw or rejected |
112
- | `timeout` | it ran past `timeout_ms` (default 5000) |
112
+ | `timeout` | it ran past the task's `timeout` (5000 ms when the task has none) |
113
113
  | `compile_error` | the module does not parse, or has no default-export function |
114
114
  | `nonserializable` | the return value cannot be serialized to JSON (a cycle, a `BigInt`) |
115
115
  | `exited` | the script called `process.exit()` |
116
116
 
117
- `error.data` is `{name, stack?}`. Set `e.name = "LimitExceeded"` in the script to tell your
118
- own failures apart. The stack is mapped back to your source lines.
117
+ `error.data` is `{name, stack?}`. `name` is the error's class name, so throwing your own
118
+ `class LimitExceeded extends Error {}` tells your failures apart; setting `e.name` overrides it,
119
+ and a thrown non-`Error` is `Thrown`. The stack is mapped back to your source lines.
119
120
 
120
121
  If the worker itself faults, it releases the task for another worker and reports no error.
121
122
 
122
- Keep the task's `timeout` above `timeout_ms`. Otherwise a slow script ends with
123
- `external.timeout`, and you cannot tell it from having no worker at all. The `script-node`
124
- template already does this.
123
+ The task's `timeout` is the script's budget, waiting in the queue included. The worker stops
124
+ the script just before it and answers `timeout`, so `external.timeout` means no worker answered
125
+ at all. With `script-node`, set it through the `timeout_ms` input.
125
126
 
126
127
  ## Limits
127
128
 
package/dist/realm.js CHANGED
@@ -36,7 +36,10 @@ function scriptStack(err) {
36
36
  }
37
37
  function describe(err, kind) {
38
38
  if (err instanceof Error) {
39
- return { kind, name: err.name, message: err.message, stack: scriptStack(err) };
39
+ // A subclass inherits `name` from Error.prototype, so `class LimitExceeded extends Error {}`
40
+ // would report "Error"; a name the script set still wins.
41
+ const name = err.name === "Error" && err.constructor.name ? err.constructor.name : err.name;
42
+ return { kind, name, message: err.message, stack: scriptStack(err) };
40
43
  }
41
44
  // A script may throw a non-Error (`throw {code: "x"}`), so name/message must not assume one.
42
45
  return { kind, name: "Thrown", message: safeText(err) };
package/dist/worker.js CHANGED
@@ -19,8 +19,17 @@ const POLL_MS = Number(process.env.POLL_MS ?? 250);
19
19
  // should return its task quickly rather than holding it for the whole budget.
20
20
  const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
21
21
  const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
22
- const PROCESS_FILTER = process.env.PROCESS ?? "";
23
- const TASK_FILTER = process.env.TASK ?? "";
22
+ // The answer has to land before the task's deadline fires, or a script that overran surfaces as
23
+ // external.timeout ("nobody answered") rather than as its own `timeout`.
24
+ const ANSWER_MARGIN_MS = 250;
25
+ // script-node's names; a renamed copy sets PROCESS. The claim requires both: this worker answers
26
+ // one task's contract, so it must never be handed another's.
27
+ const PROCESS_FILTER = process.env.PROCESS ?? "script-node";
28
+ const TASK_FILTER = process.env.TASK ?? "eval_node";
29
+ if (!PROCESS_FILTER || !TASK_FILTER) {
30
+ console.error("PROCESS and TASK must both name the queue this worker serves; neither may be empty.");
31
+ process.exit(1);
32
+ }
24
33
  // Large values arrive as refs; a bundle is one object per definition version, fetched once.
25
34
  // Refs are content hashes, so the cache never invalidates.
26
35
  const objectCache = new Map();
@@ -94,17 +103,13 @@ async function call(path, body) {
94
103
  }
95
104
  /** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
96
105
  * the nearest permanent kind, since no retry fixes the definition. */
97
- function asEvalRequest(input) {
106
+ function asEvalRequest(input, timeoutMs) {
98
107
  if (typeof input !== "object" || input === null)
99
108
  return "the task input is not an object";
100
109
  const r = input;
101
110
  if (typeof r.code !== "string")
102
111
  return "the task input has no `code` string";
103
- return {
104
- code: r.code,
105
- input: r.input,
106
- timeout_ms: typeof r.timeout_ms === "number" ? r.timeout_ms : undefined,
107
- };
112
+ return { code: r.code, input: r.input, timeout_ms: timeoutMs };
108
113
  }
109
114
  const inFlight = new Map();
110
115
  let running = true;
@@ -116,8 +121,8 @@ async function claim(n) {
116
121
  worker_id: WORKER_ID,
117
122
  limit: n,
118
123
  lease_ms: LEASE_MS,
119
- ...(PROCESS_FILTER ? { process: PROCESS_FILTER } : {}),
120
- ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
124
+ process: PROCESS_FILTER,
125
+ task: TASK_FILTER,
121
126
  });
122
127
  if (!ok) {
123
128
  // A credential problem is not transient, and polling through it looks like a healthy idle
@@ -161,6 +166,9 @@ async function answer(token, outcome) {
161
166
  await release(token);
162
167
  }
163
168
  async function run(job, signal) {
169
+ // The task's own timeout is the script's budget, moved onto the monotonic clock so a wall-clock
170
+ // step mid-run cannot stretch it. Fetching the objects below spends the same deadline.
171
+ const due = job.deadline === undefined ? undefined : performance.now() + (Date.parse(job.deadline) - Date.now());
164
172
  let resolved;
165
173
  try {
166
174
  resolved = await resolveObjects(job);
@@ -172,7 +180,16 @@ async function run(job, signal) {
172
180
  await release(job.token);
173
181
  return;
174
182
  }
175
- const req = asEvalRequest(resolved);
183
+ let timeoutMs;
184
+ if (due !== undefined) {
185
+ timeoutMs = Math.floor(due - performance.now() - ANSWER_MARGIN_MS);
186
+ if (timeoutMs <= 0) {
187
+ // Too late to run anything: the deadline fires as external.timeout, which is the truth.
188
+ await release(job.token);
189
+ return;
190
+ }
191
+ }
192
+ const req = asEvalRequest(resolved, timeoutMs);
176
193
  if (typeof req === "string") {
177
194
  await answer(job.token, {
178
195
  error: { code: "compile_error", message: req, data: { name: "BadTaskInput" } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genroc/eval-node",
3
- "version": "0.0.0-edge.e00b1c02",
3
+ "version": "0.0.0-edge.e1372854",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
package/realm.ts CHANGED
@@ -35,7 +35,10 @@ function scriptStack(err: unknown): string | undefined {
35
35
 
36
36
  function describe(err: unknown, kind: FailureKind): EvalFailure {
37
37
  if (err instanceof Error) {
38
- return { kind, name: err.name, message: err.message, stack: scriptStack(err) };
38
+ // A subclass inherits `name` from Error.prototype, so `class LimitExceeded extends Error {}`
39
+ // would report "Error"; a name the script set still wins.
40
+ const name = err.name === "Error" && err.constructor.name ? err.constructor.name : err.name;
41
+ return { kind, name, message: err.message, stack: scriptStack(err) };
39
42
  }
40
43
  // A script may throw a non-Error (`throw {code: "x"}`), so name/message must not assume one.
41
44
  return { kind, name: "Thrown", message: safeText(err) };
package/worker.ts CHANGED
@@ -22,8 +22,17 @@ const POLL_MS = Number(process.env.POLL_MS ?? 250);
22
22
  // should return its task quickly rather than holding it for the whole budget.
23
23
  const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
24
24
  const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
25
- const PROCESS_FILTER = process.env.PROCESS ?? "";
26
- const TASK_FILTER = process.env.TASK ?? "";
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 sets PROCESS. The claim requires both: this worker answers
29
+ // one task's contract, so it must never be handed another's.
30
+ const PROCESS_FILTER = process.env.PROCESS ?? "script-node";
31
+ const TASK_FILTER = process.env.TASK ?? "eval_node";
32
+ if (!PROCESS_FILTER || !TASK_FILTER) {
33
+ console.error("PROCESS and TASK must both name the queue this worker serves; neither may be empty.");
34
+ process.exit(1);
35
+ }
27
36
 
28
37
  type ObjectEntry = { path: (string | number)[]; ref: string; size: number };
29
38
 
@@ -33,7 +42,7 @@ type QueueTask = {
33
42
  task: string;
34
43
  external_input: unknown;
35
44
  objects?: ObjectEntry[];
36
- raises?: Record<string, unknown>;
45
+ deadline?: string;
37
46
  };
38
47
 
39
48
  // Large values arrive as refs; a bundle is one object per definition version, fetched once.
@@ -106,15 +115,11 @@ async function call(path: string, body: unknown): Promise<{ ok: boolean; status:
106
115
 
107
116
  /** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
108
117
  * the nearest permanent kind, since no retry fixes the definition. */
109
- function asEvalRequest(input: unknown): EvalRequest | string {
118
+ function asEvalRequest(input: unknown, timeoutMs: number | undefined): EvalRequest | string {
110
119
  if (typeof input !== "object" || input === null) return "the task input is not an object";
111
120
  const r = input as Record<string, unknown>;
112
121
  if (typeof r.code !== "string") return "the task input has no `code` string";
113
- return {
114
- code: r.code,
115
- input: r.input,
116
- timeout_ms: typeof r.timeout_ms === "number" ? r.timeout_ms : undefined,
117
- };
122
+ return { code: r.code, input: r.input, timeout_ms: timeoutMs };
118
123
  }
119
124
 
120
125
  /** genroc cannot call us, so a cancellation arrives on our own heartbeat and reaches the
@@ -133,8 +138,8 @@ async function claim(n: number): Promise<QueueTask[]> {
133
138
  worker_id: WORKER_ID,
134
139
  limit: n,
135
140
  lease_ms: LEASE_MS,
136
- ...(PROCESS_FILTER ? { process: PROCESS_FILTER } : {}),
137
- ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
141
+ process: PROCESS_FILTER,
142
+ task: TASK_FILTER,
138
143
  });
139
144
  if (!ok) {
140
145
  // A credential problem is not transient, and polling through it looks like a healthy idle
@@ -183,6 +188,9 @@ async function answer(token: string, outcome: Record<string, unknown>): Promise<
183
188
  }
184
189
 
185
190
  async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
191
+ // The task's own timeout is the script's budget, moved onto the monotonic clock so a wall-clock
192
+ // step mid-run cannot stretch it. Fetching the objects below spends the same deadline.
193
+ const due = job.deadline === undefined ? undefined : performance.now() + (Date.parse(job.deadline) - Date.now());
186
194
  let resolved: unknown;
187
195
  try {
188
196
  resolved = await resolveObjects(job);
@@ -193,7 +201,16 @@ async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
193
201
  await release(job.token);
194
202
  return;
195
203
  }
196
- const req = asEvalRequest(resolved);
204
+ let timeoutMs: number | undefined;
205
+ if (due !== undefined) {
206
+ timeoutMs = Math.floor(due - performance.now() - ANSWER_MARGIN_MS);
207
+ if (timeoutMs <= 0) {
208
+ // Too late to run anything: the deadline fires as external.timeout, which is the truth.
209
+ await release(job.token);
210
+ return;
211
+ }
212
+ }
213
+ const req = asEvalRequest(resolved, timeoutMs);
197
214
  if (typeof req === "string") {
198
215
  await answer(job.token, {
199
216
  error: { code: "compile_error", message: req, data: { name: "BadTaskInput" } },