@selvajs/solve 2.0.0-beta.11 → 2.0.0-beta.13

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/server.js CHANGED
@@ -1,249 +1,64 @@
1
+ import { t as raceAbort } from "./abort-CNJ8Sn4g.js";
2
+ import { getInputItems } from "@selvajs/schemas";
1
3
  import { GrasshopperClient, TreeBuilder, stableStringify } from "@selvajs/compute/grasshopper";
2
- import { ComputeError, ErrorCodes, enableDebugLogging, isDefinitionRef, isDefinitionRef as isDefinitionRef$1 } from "@selvajs/compute/core";
3
- import { gunzipSync, gzip } from "node:zlib";
4
- import { promisify } from "node:util";
4
+ import { ComputeError, ErrorCodes, enableDebugLogging, isDefinitionRef } from "@selvajs/compute/core";
5
5
  import { Agent, fetch } from "undici";
6
- import { NoopLogger } from "@selvajs/platform";
7
- // src/server/transform-input.ts
8
- /** The input tree a solve sends: each input's value, or its schema default when it has none. */
9
- function buildSolveInputTree(inputs, values) {
10
- return TreeBuilder.fromValues(inputs.filter((input) => input.paramType).map((input) => ({
11
- paramName: input.nickname,
12
- paramType: input.paramType,
13
- value: values[input.id] ?? input.default,
14
- minimum: input.minimum,
15
- maximum: input.maximum
16
- })));
17
- }
18
- // src/server/solve-pipeline.ts
6
+ import { gzip } from "node:zlib";
7
+ import { promisify } from "node:util";
8
+ // src/server/concurrency-probe.ts
19
9
  /**
20
- * Transport-agnostic solve pipeline: given an already-resolved solve context
21
- * (`.gh` bytes, input params + user values, a warm `SolveScheduler`), runs the
22
- * solve and returns a discriminated {@link SolveOutcome} instead of throwing
23
- * for expected failures. Auth, the database, share tokens, rate limits, and
24
- * metric sinks are the calling route's job, not this file's.
10
+ * Fallback concurrency while the first probe is still in flight, and the value
11
+ * kept whenever a probe fails or reports 0 — never send more than a single
12
+ * solve to a server whose real capacity is unknown.
25
13
  */
26
- const gzipAsync = promisify(gzip);
14
+ const FALLBACK_CONCURRENCY = 1;
27
15
  /**
28
- * Display output is base64 of already-deflated bytes, where higher levels find nothing more:
29
- * level 1 lands within 1% of the default's size, in up to a fifth of the time.
16
+ * How stale the compute server's child count may get before a solve triggers a
17
+ * re-read. Short enough that a resized pool is picked up within a few solves,
18
+ * long enough that a busy server is probed rarely — the probe rides on solve
19
+ * completion, so an idle server is never polled at all.
30
20
  */
31
- const GZIP_LEVEL = 1;
21
+ const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
32
22
  /**
33
- * A scheduler cache hit returns the same response object, so a repeat solve reuses its bytes rather
34
- * than paying to serialize and compress them again. Entries live exactly as long as the response.
35
- * Only the gzip form is kept: browsers always take it, and the JSON string would double the memory.
23
+ * rhino.compute hands out children in strict rotation and never reports "busy", so nothing
24
+ * downstream enforces a concurrency cap — send more than it has children and requests silently
25
+ * double up on one. Rhino.Compute is the only source of truth for its own child count, so this
26
+ * always probes rather than trusting an operator-supplied guess.
27
+ *
28
+ * The count is not static: children exit on their own under `-idlespan`, and `/launch-child` +
29
+ * `/recycle-children` resize the pool at runtime. So the probe re-runs after solves rather than
30
+ * only at connect.
36
31
  */
37
- const gzippedBodies = /* @__PURE__ */ new WeakMap();
38
- /** Compute-response contract version. Bump (and document the change) whenever the envelope's shape changes in a way a consumer could observe. */
39
- const COMPUTE_CONTRACT_VERSION = 1;
40
- const COMPUTE_VERSION_HEADER = "X-Selva-Compute-Version";
41
- async function runSolvePipeline(args) {
42
- const { client, signal } = args;
43
- const treeBuildStart = performance.now();
44
- const inputTree = args.inputTree ?? buildSolveInputTree(args.inputs, args.values);
45
- const treeBuildMs = performance.now() - treeBuildStart;
46
- let rhino = null;
47
- let result;
48
- let settle;
49
- const solveStart = performance.now();
50
- try {
51
- const detail = await client.scheduler.solveDetailed(args.definitionSource, inputTree, {
52
- signal,
53
- selvaevents: args.selvaEvents,
54
- onServerTiming: (t) => {
55
- rhino = t;
56
- }
57
- });
58
- result = detail.response;
59
- settle = {
60
- fromCache: detail.fromCache,
61
- definitionReuploaded: detail.definitionReuploaded
62
- };
63
- } catch (err) {
64
- const durationMs = performance.now() - solveStart;
65
- const isAbort = err instanceof Error && err.name === "AbortError" || err instanceof ComputeError && err.code === ErrorCodes.ABORTED;
66
- const isDeadline = err instanceof ComputeError && err.code === ErrorCodes.TIMEOUT_ERROR;
67
- if (isAbort || isDeadline) {
68
- if (isAbort && signal.aborted) return {
69
- kind: "client_abort",
70
- durationMs
71
- };
72
- return {
73
- kind: "timeout",
74
- durationMs,
75
- message: `Solve exceeded the ${Math.round(args.solveDeadlineMs / 1e3)}s deadline.`
76
- };
77
- }
78
- if (err instanceof ComputeError) {
79
- if (err.code === ErrorCodes.RESPONSE_TOO_LARGE) return { kind: "too_large" };
80
- if (err.code === ErrorCodes.QUEUE_FULL) return {
81
- kind: "shed",
82
- durationMs,
83
- reason: "queue_full",
84
- retryAfterSeconds: 1,
85
- message: "Compute server is at capacity. Retry shortly."
86
- };
87
- if (err.code === ErrorCodes.QUEUE_TIMEOUT) return {
88
- kind: "shed",
89
- durationMs,
90
- reason: "queue_timeout",
91
- retryAfterSeconds: 1,
92
- message: "Compute request waited too long in the queue. Retry shortly."
93
- };
94
- }
95
- return {
96
- kind: "compute_error",
97
- durationMs,
98
- error: err
99
- };
100
- }
101
- const solveMs = performance.now() - solveStart;
102
- const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
103
- const remembered = clientWantsGzip ? gzippedBodies.get(result) : void 0;
104
- let compressed = remembered?.body ?? null;
105
- let serializedBytes = remembered?.serializedBytes ?? 0;
106
- let serialized = "";
107
- let serializeMs = 0;
108
- let gzipMs = 0;
109
- if (!remembered) {
110
- const serializeStart = performance.now();
32
+ function createConcurrencyProbe(client, debugLog) {
33
+ let maxConcurrent = FALLBACK_CONCURRENCY;
34
+ let lastProbeAt = 0;
35
+ let probing = false;
36
+ /** `initialize: false` keeps this from spawning children on an idle server, so a cold one reports 0. */
37
+ async function refresh(scheduler) {
38
+ if (probing) return;
39
+ probing = true;
111
40
  try {
112
- serialized = JSON.stringify(result);
113
- } catch (err) {
114
- if (err instanceof RangeError) return { kind: "too_large" };
115
- throw err;
116
- }
117
- if (serialized.length > args.responseMaxBytes) return { kind: "too_large" };
118
- serializeMs = performance.now() - serializeStart;
119
- serializedBytes = serialized.length;
120
- if (clientWantsGzip && serialized.length > 1024) {
121
- const gzipStart = performance.now();
122
- compressed = new Uint8Array(await gzipAsync(Buffer.from(serialized), { level: GZIP_LEVEL }));
123
- gzipMs = performance.now() - gzipStart;
124
- gzippedBodies.set(result, {
125
- body: compressed,
126
- serializedBytes
127
- });
41
+ const children = await Promise.resolve(client.serverStats?.getActiveChildren({ initialize: false })).catch(() => null);
42
+ lastProbeAt = Date.now();
43
+ const next = !children || children <= 0 ? FALLBACK_CONCURRENCY : children;
44
+ if (next === maxConcurrent) return;
45
+ debugLog(`[Compute/client-cache] concurrency ${maxConcurrent} → ${next} (server child count)`);
46
+ maxConcurrent = next;
47
+ scheduler?.setMaxConcurrent(next);
48
+ } finally {
49
+ probing = false;
128
50
  }
129
51
  }
130
- const serverTotalMs = performance.now() - args.loadStartMs;
131
- const headers = {
132
- "Content-Type": "application/json",
133
- "Server-Timing": buildServerTiming({
134
- defLoadMs: args.defLoadMs,
135
- treeBuildMs,
136
- solveMs,
137
- serializeMs,
138
- gzipMs,
139
- serverTotalMs,
140
- rhino,
141
- settle,
142
- byteRefOutcome: args.byteRefOutcome,
143
- prepMarks: args.prepMarks ?? []
144
- }),
145
- [COMPUTE_VERSION_HEADER]: String(1),
146
- Vary: "Accept-Encoding"
147
- };
148
- const metrics = {
149
- treeBuildMs,
150
- solveMs,
151
- serializeMs,
152
- gzipMs,
153
- serverTotalMs,
154
- serializedBytes,
155
- compressedBytes: compressed?.byteLength ?? null
156
- };
157
- let envelope;
158
- if (compressed) {
159
- headers["Content-Encoding"] = "gzip";
160
- headers["Content-Length"] = String(compressed.byteLength);
161
- envelope = {
162
- body: compressed,
163
- encoding: "gzip",
164
- headers,
165
- result,
166
- metrics
167
- };
168
- } else {
169
- headers["Content-Length"] = String(Buffer.byteLength(serialized));
170
- envelope = {
171
- body: serialized,
172
- headers,
173
- result,
174
- metrics
175
- };
176
- }
177
- return {
178
- kind: "ok",
179
- envelope,
180
- solveMs,
181
- errorCount: result.errors?.length ?? 0,
182
- warningCount: result.warnings?.length ?? 0
183
- };
184
- }
185
- /**
186
- * Re-key a coalesced envelope to a single waiter's `Accept-Encoding`.
187
- *
188
- * The single-flight coalescer runs ONE pipeline execution for N identical
189
- * concurrent solves and hands every waiter the same {@link SolveEnvelope},
190
- * baked from the FIRST caller's `Accept-Encoding`. A later waiter with a
191
- * different `Accept-Encoding` would otherwise get a body labelled with the
192
- * wrong encoding — `Vary` can't help here, since it's one shared object, not a
193
- * cache lookup. Encoding is deliberately left out of the coalesce key so mixed
194
- * clients still coalesce; this adapts the shared result to each waiter instead.
195
- *
196
- * Only the correctness-critical direction is adapted: a gzip envelope served to
197
- * a non-gzip waiter is gunzipped back to JSON. The reverse (plain JSON to a
198
- * gzip-capable waiter) is left uncompressed — correct, just not maximally small.
199
- */
200
- function adaptEnvelopeToEncoding(envelope, acceptEncoding) {
201
- const waiterWantsGzip = /\bgzip\b/i.test(acceptEncoding);
202
- if (envelope.encoding === "gzip" && !waiterWantsGzip) {
203
- const json = gunzipSync(envelope.body instanceof Uint8Array ? envelope.body : Buffer.from(envelope.body)).toString("utf8");
204
- const headers = { ...envelope.headers };
205
- delete headers["Content-Encoding"];
206
- headers["Content-Length"] = String(Buffer.byteLength(json));
207
- return {
208
- body: json,
209
- headers
210
- };
211
- }
212
52
  return {
213
- body: envelope.body,
214
- headers: envelope.headers
53
+ get maxConcurrent() {
54
+ return maxConcurrent;
55
+ },
56
+ refresh: () => refresh(),
57
+ refreshIfDue(scheduler) {
58
+ if (Date.now() - lastProbeAt >= CONCURRENCY_PROBE_INTERVAL_MS) refresh(scheduler);
59
+ }
215
60
  };
216
61
  }
217
- /**
218
- * `total` is the server's headers-to-out wall time, so browser `ttfb − total`
219
- * ≈ network+send latency. When the compute server reports its own timing
220
- * (VektorNode fork), `rhino_*` is time on the compute server and `compute_link`
221
- * is everything between it (network + queue wait). `rhino_cache` is 1 when the
222
- * compute server answered from its own result cache.
223
- */
224
- function buildServerTiming(parts) {
225
- let header = `load;dur=${parts.defLoadMs.toFixed(1)}, tree;dur=${parts.treeBuildMs.toFixed(1)}, solve;dur=${parts.solveMs.toFixed(1)}, serialize;dur=${parts.serializeMs.toFixed(1)}, gzip;dur=${parts.gzipMs.toFixed(1)}, total;dur=${parts.serverTotalMs.toFixed(1)}`;
226
- const rhino = parts.rhino;
227
- if (rhino) {
228
- const decode = rhino.decode ?? 0;
229
- const solve = rhino.solve ?? 0;
230
- const encode = rhino.encode ?? 0;
231
- const computeLinkMs = Math.max(0, parts.solveMs - decode - solve - encode);
232
- header += `, rhino_decode;dur=${decode.toFixed(1)}, rhino_solve;dur=${solve.toFixed(1)}, rhino_encode;dur=${encode.toFixed(1)}, compute_link;dur=${computeLinkMs.toFixed(1)}, rhino_cache;dur=${rhino.cacheHit ? 1 : 0}`;
233
- if (rhino.gh !== void 0) header += `, rhino_gh;dur=${rhino.gh.toFixed(1)}`;
234
- if (rhino.recomputed) header += `, rhino_recomputed;desc="${rhino.recomputed.ran}/${rhino.recomputed.total}"`;
235
- }
236
- for (const [label, ms] of parts.prepMarks) header += `, p_${label};dur=${ms.toFixed(1)}`;
237
- const settle = parts.settle;
238
- header += `, selva_cache;dur=${settle.fromCache ? 1 : 0}`;
239
- if (settle.definitionReuploaded !== void 0) header += `, def_reupload;dur=${settle.definitionReuploaded ? 1 : 0}`;
240
- const byteRef = parts.byteRefOutcome;
241
- if (byteRef) {
242
- const verdict = !byteRef.loaded ? "skipped" : byteRef.fromCache ? "hit" : "miss";
243
- header += `, def_bytes;desc=${verdict}`;
244
- }
245
- return header;
246
- }
247
62
  // src/server/long-fetch.ts
248
63
  /** undici's default `headersTimeout` and `bodyTimeout`, which `timeoutMs` can't extend. */
249
64
  const UNDICI_DEFAULT_LIMIT_MS = 3e5;
@@ -254,30 +69,45 @@ const unlimited = new Agent({
254
69
  });
255
70
  const unlimitedFetch = ((input, init) => fetch(input, {
256
71
  ...init,
72
+ signal: init?.signal ?? AbortSignal.timeout(UNDICI_DEFAULT_LIMIT_MS),
257
73
  dispatcher: unlimited
258
74
  }));
259
75
  /** The fetch for a solve deadline: `undefined` means the global one reaches it. */
260
76
  function fetchForDeadline(deadlineMs) {
261
77
  return deadlineMs >= UNDICI_DEFAULT_LIMIT_MS ? unlimitedFetch : void 0;
262
78
  }
263
- // src/server/client-cache.ts
264
- function serverIdentity(server) {
265
- return server.id;
266
- }
267
- const DEFAULT_MAX_WARM_COMPUTE_SERVERS = 16;
79
+ // src/server/server-limits.ts
80
+ /** Rhino.Compute's own default `--timeout`, for a server that can't report its own. */
81
+ const DEFAULT_SOLVE_DEADLINE_MS = 1e5;
268
82
  /**
269
- * Fallback concurrency while the first probe is still in flight, and the value
270
- * kept whenever a probe fails or reports 0 — never send more than a single
271
- * solve to a server whose real capacity is unknown.
83
+ * Ceiling on any solve deadline, whatever the server reports. Bounds how long a request, its
84
+ * live-event token and a delegated session stay open; past an hour a solve belongs in a job.
272
85
  */
273
- const FALLBACK_CONCURRENCY = 1;
86
+ const MAX_SOLVE_DEADLINE_MS = 36e5;
87
+ /** Selva waits this much past the server's timeout, so Compute's own error arrives first. */
88
+ const DEADLINE_SLACK_MS = 5e3;
89
+ function solveLimitsFor(reported) {
90
+ if (!reported) return {
91
+ solveDeadlineMs: DEFAULT_SOLVE_DEADLINE_MS,
92
+ maxRequestBytes: null,
93
+ reuseDefinitionCache: false
94
+ };
95
+ return {
96
+ solveDeadlineMs: Math.min(reported.timeoutSeconds * 1e3 + DEADLINE_SLACK_MS, MAX_SOLVE_DEADLINE_MS),
97
+ maxRequestBytes: reported.maxRequestBytes,
98
+ reuseDefinitionCache: reported.signalsStaleDefinition
99
+ };
100
+ }
101
+ function sameSolveLimits(a, b) {
102
+ return a.solveDeadlineMs === b.solveDeadlineMs && a.maxRequestBytes === b.maxRequestBytes && a.reuseDefinitionCache === b.reuseDefinitionCache;
103
+ }
104
+ // src/server/client-cache.ts
105
+ const DEFAULT_MAX_WARM_COMPUTE_SERVERS = 16;
274
106
  /**
275
- * How stale the compute server's child count may get before a solve triggers a
276
- * re-read. Short enough that a resized pool is picked up within a few solves,
277
- * long enough that a busy server is probed rarely — the probe rides on solve
278
- * completion, so an idle server is never polled at all.
107
+ * How often a warm client re-reads its server's limits, riding on solve completion like the
108
+ * concurrency probe. An operator who changes `--timeout` sees it within this window.
279
109
  */
280
- const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
110
+ const LIMITS_RECHECK_MS = 3e5;
281
111
  /**
282
112
  * How long a failed build is remembered before the next `getClient` retries the
283
113
  * handshake. Without this, every solve against a down server pays the full probe
@@ -288,18 +118,40 @@ const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
288
118
  * out a long penalty window before the app notices.
289
119
  */
290
120
  const FAILED_BUILD_TTL_MS = 5e3;
121
+ function describeSettledSolve(result) {
122
+ const lines = [`[Compute/selva-cache] ${result.fromCache ? "HIT — served from cache (no compute call)" : "miss — went to Rhino.Compute"} (${Math.round(result.durationMs)}ms)`];
123
+ if (result.fromCache) return lines;
124
+ lines.push(result.definitionReuploaded === true ? "[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)" : result.definitionReuploaded === false ? "[Compute/def-cache] HIT — definition reused from server cache (no upload)" : "[Compute/def-cache] n/a — reuse disabled or non-reusable definition");
125
+ const errors = result.response?.errors;
126
+ if (Array.isArray(errors) && errors.length > 0) lines.push(`[Compute/rhino-cache] ${errors.length} GH error(s); not cached by Rhino.Compute.`);
127
+ return lines;
128
+ }
291
129
  function createClientCache(config) {
292
130
  const maxWarmComputeServers = config.maxWarmComputeServers ?? DEFAULT_MAX_WARM_COMPUTE_SERVERS;
293
131
  const cache = /* @__PURE__ */ new Map();
294
132
  const pending = /* @__PURE__ */ new Map();
295
133
  const failed = /* @__PURE__ */ new Map();
134
+ const draining = /* @__PURE__ */ new Set();
296
135
  if (config.debug === "verbose") enableDebugLogging();
297
136
  const debugLog = (message) => {
298
137
  if (config.debug) config.onDebugLog?.(message);
299
138
  };
139
+ function isIdle({ scheduler }) {
140
+ return scheduler.inFlightCount === 0 && scheduler.queueDepth === 0;
141
+ }
142
+ /** Disposing a busy scheduler aborts its solves, so a config write would fail them all. */
143
+ function retire(entry) {
144
+ if (isIdle(entry)) entry.scheduler.dispose();
145
+ else draining.add(entry);
146
+ }
147
+ function finishDraining(entry) {
148
+ if (!draining.has(entry) || !isIdle(entry)) return;
149
+ draining.delete(entry);
150
+ entry.scheduler.dispose();
151
+ }
300
152
  async function build(server, definitionGuid) {
301
153
  const debugVerbose = config.debug === "verbose";
302
- const longFetch = fetchForDeadline(config.solveDeadlineMs);
154
+ const longFetch = fetchForDeadline(MAX_SOLVE_DEADLINE_MS);
303
155
  const clientConfig = {
304
156
  serverUrl: server.serverUrl,
305
157
  apiKey: server.apiKey,
@@ -323,60 +175,54 @@ function createClientCache(config) {
323
175
  };
324
176
  if (definitionGuid) clientConfig.headers = { "X-Selva-Definition": definitionGuid };
325
177
  const client = await GrasshopperClient.create(clientConfig);
326
- let maxConcurrent = FALLBACK_CONCURRENCY;
327
- let lastProbeAt = 0;
328
- let probing = false;
329
- /**
330
- * Best-effort re-read of the server's child count. Never awaited by a caller:
331
- * a probe must not add a round-trip to the solve path, so a changed count takes
332
- * effect on the NEXT solve rather than the one that triggered the probe.
333
- *
334
- * `initialize: false` keeps this from spawning children on an idle server —
335
- * which also means a cold server honestly reports 0. Both a failed probe and a
336
- * 0 result fall back to `FALLBACK_CONCURRENCY` (1): an unknown-capacity server
337
- * is safer under-used than oversent.
338
- */
339
- async function refreshConcurrency(scheduler) {
340
- if (probing) return;
341
- probing = true;
342
- try {
343
- const children = await Promise.resolve(client.serverStats?.getActiveChildren({ initialize: false })).catch(() => null);
344
- lastProbeAt = Date.now();
345
- const next = !children || children <= 0 ? FALLBACK_CONCURRENCY : children;
346
- if (next === maxConcurrent) return;
347
- debugLog(`[Compute/client-cache] concurrency ${maxConcurrent} → ${next} (server child count)`);
348
- maxConcurrent = next;
349
- scheduler?.setMaxConcurrent(next);
350
- } finally {
351
- probing = false;
352
- }
353
- }
354
- await refreshConcurrency();
178
+ const probe = createConcurrencyProbe(client, debugLog);
179
+ const readLimits = async () => solveLimitsFor(await Promise.resolve(client.serverStats?.getLimits?.()).catch(() => null) ?? null);
180
+ const [limits] = await Promise.all([readLimits(), probe.refresh()]);
181
+ debugLog(`[Compute/client-cache] server ${server.id}: deadline ${limits.solveDeadlineMs}ms, request cap ${limits.maxRequestBytes ?? "not reported"}, pointer reuse ${limits.reuseDefinitionCache}`);
182
+ let limitsCheckedAt = Date.now();
183
+ let checkingLimits = false;
184
+ const recheckLimitsIfDue = () => {
185
+ if (checkingLimits || Date.now() - limitsCheckedAt < LIMITS_RECHECK_MS) return;
186
+ checkingLimits = true;
187
+ readLimits().then((next) => {
188
+ limitsCheckedAt = Date.now();
189
+ if (sameSolveLimits(next, limits)) return;
190
+ debugLog(`[Compute/client-cache] server ${server.id} limits changed — rebuilding`);
191
+ if (cache.get(server.id) === entry) evictEntry(server.id);
192
+ }).finally(() => {
193
+ checkingLimits = false;
194
+ });
195
+ };
355
196
  const scheduler = client.createScheduler({
356
197
  mode: "queue",
357
- maxConcurrent,
198
+ maxConcurrent: probe.maxConcurrent,
358
199
  maxQueueDepth: config.maxQueueDepth || void 0,
359
200
  queueWaitMs: config.queueWaitMs || void 0,
360
- timeoutMs: config.solveDeadlineMs,
201
+ timeoutMs: limits.solveDeadlineMs,
361
202
  cache: config.responseCacheMaxBytes > 0 ? { maxBytes: config.responseCacheMaxBytes } : false,
362
- reuseServerDefinitionCache: config.reuseServerDefinitionCache,
203
+ reuseServerDefinitionCache: limits.reuseDefinitionCache,
363
204
  onSettle: (_ctx, result) => {
205
+ queueMicrotask(() => finishDraining(entry));
364
206
  if (result.status !== "success") return;
365
- if (result.fromCache !== true && Date.now() - lastProbeAt >= CONCURRENCY_PROBE_INTERVAL_MS) refreshConcurrency(scheduler);
366
- if (!config.debug) return;
367
- debugLog(`[Compute/selva-cache] ${result.fromCache ? "HIT — served from cache (no compute call)" : "miss — went to Rhino.Compute"} (${Math.round(result.durationMs)}ms)`);
368
- if (!result.fromCache) debugLog(result.definitionReuploaded === true ? "[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)" : result.definitionReuploaded === false ? "[Compute/def-cache] HIT — definition reused from server cache (no upload)" : "[Compute/def-cache] n/a — reuse disabled or non-reusable definition");
369
- if (!result.fromCache) {
370
- const errs = result.response?.errors;
371
- const errCount = Array.isArray(errs) ? errs.length : 0;
372
- if (errCount > 0) debugLog(`[Compute/rhino-cache] ${errCount} GH error(s); not cached by Rhino.Compute. First: ${JSON.stringify(errs[0])}`);
207
+ if (result.fromCache !== true) {
208
+ probe.refreshIfDue(scheduler);
209
+ recheckLimitsIfDue();
373
210
  }
211
+ if (config.debug) describeSettledSolve(result).forEach(debugLog);
374
212
  }
375
213
  });
376
- return {
214
+ const entry = {
377
215
  client,
378
- scheduler
216
+ scheduler,
217
+ limits
379
218
  };
219
+ return entry;
220
+ }
221
+ function evictEntry(id) {
222
+ const entry = cache.get(id);
223
+ if (!entry) return;
224
+ cache.delete(id);
225
+ retire(entry);
380
226
  }
381
227
  return {
382
228
  async getClient(server, opts) {
@@ -388,7 +234,7 @@ function createClientCache(config) {
388
234
  return existing;
389
235
  }
390
236
  const inFlight = pending.get(key);
391
- if (inFlight) return inFlight;
237
+ if (inFlight) return inFlight.promise;
392
238
  const recentFailure = failed.get(key);
393
239
  if (recentFailure) {
394
240
  if (Date.now() - recentFailure.at < FAILED_BUILD_TTL_MS) {
@@ -398,39 +244,52 @@ function createClientCache(config) {
398
244
  failed.delete(key);
399
245
  }
400
246
  debugLog(`[Compute/client-cache] miss — building warm client for server ${key}`);
401
- const buildPromise = (async () => {
247
+ const token = { stale: false };
248
+ const promise = (async () => {
402
249
  let entry;
403
250
  try {
404
251
  entry = await build(server, opts?.definitionGuid);
405
252
  } catch (err) {
406
- failed.set(key, {
253
+ if (!token.stale) failed.set(key, {
407
254
  error: err,
408
255
  at: Date.now()
409
256
  });
410
257
  throw err;
411
258
  }
259
+ if (token.stale) {
260
+ entry.scheduler.dispose();
261
+ throw new ComputeError(`Compute server ${key} changed while connecting.`, ErrorCodes.ABORTED);
262
+ }
412
263
  if (cache.size >= maxWarmComputeServers) {
413
264
  const oldestKey = cache.keys().next().value;
414
265
  if (oldestKey !== void 0) {
415
266
  debugLog(`[Compute/client-cache] LRU evicted warm client for server ${oldestKey} (cap ${maxWarmComputeServers})`);
416
- cache.get(oldestKey)?.scheduler.dispose();
267
+ const oldest = cache.get(oldestKey);
417
268
  cache.delete(oldestKey);
269
+ if (oldest) retire(oldest);
418
270
  }
419
271
  }
420
272
  cache.set(key, entry);
421
273
  return entry;
422
- })().finally(() => pending.delete(key));
423
- pending.set(key, buildPromise);
424
- return buildPromise;
274
+ })().finally(() => {
275
+ if (pending.get(key)?.token === token) pending.delete(key);
276
+ });
277
+ pending.set(key, {
278
+ promise,
279
+ token
280
+ });
281
+ return promise;
425
282
  },
426
283
  evict(id) {
427
- const key = id;
428
- failed.delete(key);
429
- const entry = cache.get(key);
430
- if (entry) {
431
- debugLog(`[Compute/client-cache] evicted warm client for server ${key} (config change)`);
432
- entry.scheduler.dispose();
433
- cache.delete(key);
284
+ failed.delete(id);
285
+ const build = pending.get(id);
286
+ if (build) {
287
+ build.token.stale = true;
288
+ pending.delete(id);
289
+ }
290
+ if (cache.has(id)) {
291
+ debugLog(`[Compute/client-cache] evicted warm client for server ${id} (config change)`);
292
+ evictEntry(id);
434
293
  }
435
294
  },
436
295
  solveCacheStats() {
@@ -458,48 +317,99 @@ function createClientCache(config) {
458
317
  },
459
318
  disposeAll() {
460
319
  debugLog(`[Compute/client-cache] disposing all ${cache.size} warm client(s)`);
461
- for (const entry of cache.values()) entry.scheduler.dispose();
320
+ for (const entry of [...cache.values(), ...draining]) entry.scheduler.dispose();
321
+ for (const build of pending.values()) build.token.stale = true;
322
+ pending.clear();
462
323
  cache.clear();
324
+ draining.clear();
463
325
  failed.clear();
464
326
  }
465
327
  };
466
328
  }
467
329
  // src/server/definition-byte-cache.ts
468
330
  /**
331
+ * In-process cache of `.gh` definition bytes, keyed by immutable **version id**.
332
+ *
333
+ * Turns an eager `storage.get(fileKey)` on every solve request into a lazy
334
+ * `DefinitionRef.load()`: the scheduler calls `load()` only when an upload is
335
+ * unavoidable (a pointer-known re-solve never touches storage or this cache).
336
+ *
337
+ * The key MUST be the version id, never a `fileKey` — a delete-latest-then-
338
+ * reupload can reuse a `fileKey` for different content, which would silently
339
+ * serve one version's bytes for another with no diagnostic. Callers pass
340
+ * `version.id`, and the returned `DefinitionRef.key` is that same id, so the
341
+ * scheduler's own result/pointer cache keys on the same immutable identity.
342
+ *
343
+ * Eviction is LRU by total byte budget, not entry count — definitions range
344
+ * from tens of KB to hundreds of MB, so a count cap would either pin
345
+ * gigabytes or evict uselessly. `COMPUTE_DEFINITION_CACHE_MB` sets the
346
+ * budget (see `createDefinitionByteCache`). No TTL: version ids are
347
+ * immutable, so a cached entry can never go stale. An entry larger than the
348
+ * whole budget is served but never retained.
349
+ */
350
+ /** A `ByteCacheRef` carries `outcome`, which a plain `DefinitionRef` never has. */
351
+ function isByteCacheRef(source) {
352
+ return typeof source === "object" && source !== null && "outcome" in source;
353
+ }
354
+ /**
469
355
  * @param maxBytes total retained-byte budget; `0` (or negative) disables caching
470
356
  * entirely — every `load()` calls the loader and nothing is retained.
471
357
  */
472
358
  function createDefinitionByteCache(maxBytes) {
473
359
  const budget = Math.max(0, Math.floor(maxBytes));
474
360
  const cache = /* @__PURE__ */ new Map();
361
+ const loading = /* @__PURE__ */ new Map();
475
362
  let retainedBytes = 0;
476
363
  const stats = {
477
364
  hits: 0,
478
365
  misses: 0,
479
366
  evictions: 0
480
367
  };
481
- function touch(key, entry) {
482
- cache.delete(key);
483
- cache.set(key, entry);
484
- }
485
368
  function evictToFit() {
486
- while (retainedBytes > budget && cache.size > 0) {
487
- const oldestKey = cache.keys().next().value;
488
- const oldest = cache.get(oldestKey);
489
- cache.delete(oldestKey);
490
- retainedBytes -= oldest.bytes.byteLength;
369
+ for (const [key, bytes] of cache) {
370
+ if (retainedBytes <= budget) break;
371
+ cache.delete(key);
372
+ retainedBytes -= bytes.byteLength;
491
373
  stats.evictions += 1;
492
374
  }
493
375
  }
494
376
  function store(key, bytes) {
495
377
  if (budget === 0) return;
496
378
  if (bytes.byteLength > budget) return;
497
- const existing = cache.get(key);
498
- if (existing) retainedBytes -= existing.bytes.byteLength;
499
- cache.set(key, { bytes });
379
+ cache.set(key, bytes);
500
380
  retainedBytes += bytes.byteLength;
501
381
  evictToFit();
502
382
  }
383
+ function startLoad(key, load) {
384
+ const controller = new AbortController();
385
+ const entry = {
386
+ controller,
387
+ waiters: 0,
388
+ promise: (async () => {
389
+ const bytes = await load(controller.signal);
390
+ store(key, bytes);
391
+ return bytes;
392
+ })().finally(() => {
393
+ if (loading.get(key) === entry) loading.delete(key);
394
+ })
395
+ };
396
+ loading.set(key, entry);
397
+ return entry;
398
+ }
399
+ /**
400
+ * A caller gone stops waiting, but the read runs on for whoever else is on it. A caller with no
401
+ * signal can't leave, so it stays counted and the read is never aborted under it.
402
+ */
403
+ async function wait(entry, signal) {
404
+ entry.waiters += 1;
405
+ if (!signal) return entry.promise;
406
+ try {
407
+ return await raceAbort(entry.promise, signal);
408
+ } finally {
409
+ entry.waiters -= 1;
410
+ if (entry.waiters === 0 && signal.aborted) entry.controller.abort();
411
+ }
412
+ }
503
413
  return {
504
414
  getOrLoad(versionId, load) {
505
415
  const outcome = {
@@ -509,21 +419,29 @@ function createDefinitionByteCache(maxBytes) {
509
419
  return {
510
420
  key: versionId,
511
421
  outcome,
512
- load: async () => {
422
+ load: async function read(signal) {
423
+ outcome.loaded = true;
513
424
  const hit = cache.get(versionId);
514
425
  if (hit) {
515
- touch(versionId, hit);
426
+ cache.delete(versionId);
427
+ cache.set(versionId, hit);
516
428
  stats.hits += 1;
517
- outcome.loaded = true;
518
429
  outcome.fromCache = true;
519
- return hit.bytes;
430
+ return hit;
431
+ }
432
+ const running = loading.get(versionId);
433
+ if (running?.controller.signal.aborted) {
434
+ const woundDown = running.promise.then(() => void 0, () => void 0);
435
+ await (signal ? raceAbort(woundDown, signal) : woundDown);
436
+ return read(signal);
437
+ }
438
+ if (running) {
439
+ stats.hits += 1;
440
+ outcome.fromCache = true;
441
+ return wait(running, signal);
520
442
  }
521
443
  stats.misses += 1;
522
- outcome.loaded = true;
523
- outcome.fromCache = false;
524
- const bytes = await load();
525
- store(versionId, bytes);
526
- return bytes;
444
+ return wait(startLoad(versionId, load), signal);
527
445
  }
528
446
  };
529
447
  },
@@ -542,73 +460,345 @@ function createDefinitionByteCache(maxBytes) {
542
460
  }
543
461
  };
544
462
  }
463
+ // src/server/response-encoding.ts
464
+ /**
465
+ * Turns one solve's response into each caller's wire form. Runs after single-flight, not inside
466
+ * it: coalesced callers can want different encodings, and each reports its own timing.
467
+ */
468
+ const gzipAsync = promisify(gzip);
469
+ /**
470
+ * Display output is base64 of already-deflated bytes, where higher levels find nothing more:
471
+ * level 1 lands within 1% of the default's size, in up to a fifth of the time.
472
+ */
473
+ const GZIP_LEVEL = 1;
474
+ /** Below this gzip isn't worth the CPU for the wire savings. */
475
+ const GZIP_MIN_BYTES = 1024;
476
+ /** Compute-response contract version. Bump (and document the change) whenever the envelope's shape changes in a way a consumer could observe. */
477
+ const COMPUTE_CONTRACT_VERSION = 1;
478
+ const COMPUTE_VERSION_HEADER = "X-Selva-Compute-Version";
479
+ function buildEnvelope(encoded, result, serverTiming, metrics) {
480
+ const headers = {
481
+ "Content-Type": "application/json",
482
+ "Server-Timing": serverTiming,
483
+ [COMPUTE_VERSION_HEADER]: String(1),
484
+ Vary: "Accept-Encoding"
485
+ };
486
+ const { body, encoding } = encoded;
487
+ if (encoding === "gzip") headers["Content-Encoding"] = "gzip";
488
+ headers["Content-Length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength);
489
+ return {
490
+ body,
491
+ ...encoding ? { encoding } : {},
492
+ headers,
493
+ result,
494
+ metrics
495
+ };
496
+ }
497
+ /** Whether an `Accept-Encoding` header takes gzip. `gzip;q=0` is a client refusing it. */
498
+ function acceptsGzip(acceptEncoding) {
499
+ return acceptEncoding.split(",").some((part) => {
500
+ const [coding, ...params] = part.split(";").map((s) => s.trim().toLowerCase());
501
+ if (coding !== "gzip") return false;
502
+ const q = params.find((p) => p.startsWith("q="));
503
+ return q === void 0 || Number(q.slice(2)) > 0;
504
+ });
505
+ }
506
+ /**
507
+ * A scheduler cache hit returns the same response object, so a repeat solve reuses its bytes rather
508
+ * than paying to serialize and compress them again. Entries live exactly as long as the response.
509
+ * Only the gzip form is kept: browsers always take it, and the JSON string would double the memory.
510
+ * Holds the promise, so callers arriving during the compression share it.
511
+ */
512
+ const gzippedBodies = /* @__PURE__ */ new WeakMap();
513
+ function gzipOnce(response, json, serializedBytes) {
514
+ const pending = gzipAsync(Buffer.from(json), { level: GZIP_LEVEL }).then((body) => ({
515
+ body,
516
+ serializedBytes
517
+ }));
518
+ gzippedBodies.set(response, pending);
519
+ pending.catch(() => gzippedBodies.delete(response));
520
+ return pending;
521
+ }
522
+ /**
523
+ * One per solve. The JSON is kept here rather than beside the gzip body: it only has to outlive the
524
+ * callers of this flight, who would otherwise each stringify the same response and hold a copy.
525
+ */
526
+ function createResponseEncoder(response, maxBytes) {
527
+ let serialized;
528
+ function serialize() {
529
+ if (serialized !== void 0) return serialized;
530
+ try {
531
+ const json = JSON.stringify(response);
532
+ const bytes = Buffer.byteLength(json);
533
+ serialized = bytes > maxBytes ? "too_large" : {
534
+ json,
535
+ bytes
536
+ };
537
+ } catch (err) {
538
+ if (!(err instanceof RangeError)) throw err;
539
+ serialized = "too_large";
540
+ }
541
+ return serialized;
542
+ }
543
+ return { async encode(acceptEncoding) {
544
+ const wantsGzip = acceptsGzip(acceptEncoding);
545
+ let gzipped = wantsGzip ? gzippedBodies.get(response) : void 0;
546
+ let serializeMs = 0;
547
+ if (!gzipped) {
548
+ const serializeStart = performance.now();
549
+ const plain = serialize();
550
+ serializeMs = performance.now() - serializeStart;
551
+ if (plain === "too_large") {
552
+ if (wantsGzip) gzippedBodies.set(response, Promise.resolve("too_large"));
553
+ return "too_large";
554
+ }
555
+ if (!wantsGzip || plain.bytes <= GZIP_MIN_BYTES) return {
556
+ body: plain.json,
557
+ serializedBytes: plain.bytes,
558
+ serializeMs,
559
+ gzipMs: 0
560
+ };
561
+ gzipped = gzipOnce(response, plain.json, plain.bytes);
562
+ }
563
+ const gzipStart = performance.now();
564
+ const verdict = await gzipped;
565
+ if (verdict === "too_large") return verdict;
566
+ return {
567
+ body: verdict.body,
568
+ encoding: "gzip",
569
+ serializedBytes: verdict.serializedBytes,
570
+ serializeMs,
571
+ gzipMs: performance.now() - gzipStart
572
+ };
573
+ } };
574
+ }
575
+ // src/server/server-timing.ts
576
+ /**
577
+ * `total` is the server's headers-to-out wall time, so browser `ttfb − total`
578
+ * ≈ network+send latency. When the compute server reports its own timing
579
+ * (VektorNode fork), `rhino_*` is time on the compute server and `compute_link`
580
+ * is everything between it (network + queue wait). `rhino_cache` is 1 when the
581
+ * compute server answered from its own result cache.
582
+ */
583
+ function buildServerTiming(parts) {
584
+ let header = `load;dur=${parts.defLoadMs.toFixed(1)}, tree;dur=${parts.treeBuildMs.toFixed(1)}, solve;dur=${parts.solveMs.toFixed(1)}, serialize;dur=${parts.serializeMs.toFixed(1)}, gzip;dur=${parts.gzipMs.toFixed(1)}, total;dur=${parts.serverTotalMs.toFixed(1)}`;
585
+ const rhino = parts.rhino;
586
+ if (rhino) {
587
+ const decode = rhino.decode ?? 0;
588
+ const solve = rhino.solve ?? 0;
589
+ const encode = rhino.encode ?? 0;
590
+ const computeLinkMs = Math.max(0, parts.solveMs - decode - solve - encode);
591
+ header += `, rhino_decode;dur=${decode.toFixed(1)}, rhino_solve;dur=${solve.toFixed(1)}, rhino_encode;dur=${encode.toFixed(1)}, compute_link;dur=${computeLinkMs.toFixed(1)}, rhino_cache;dur=${rhino.cacheHit ? 1 : 0}`;
592
+ if (rhino.gh !== void 0) header += `, rhino_gh;dur=${rhino.gh.toFixed(1)}`;
593
+ if (rhino.recomputed) header += `, rhino_recomputed;desc="${rhino.recomputed.ran}/${rhino.recomputed.total}"`;
594
+ }
595
+ for (const [label, ms] of parts.prepMarks) header += `, p_${label};dur=${ms.toFixed(1)}`;
596
+ const settle = parts.settle;
597
+ header += `, selva_cache;dur=${settle.fromCache ? 1 : 0}`;
598
+ if (settle.definitionReuploaded !== void 0) header += `, def_reupload;dur=${settle.definitionReuploaded ? 1 : 0}`;
599
+ if (parts.defBytes) header += `, def_bytes;desc=${parts.defBytes}`;
600
+ return header;
601
+ }
545
602
  // src/server/solve-cache-single-flight.ts
546
603
  function createSolveCacheSingleFlight(options = {}) {
547
604
  const inflight = /* @__PURE__ */ new Map();
605
+ function free(key, flight) {
606
+ if (inflight.get(key) === flight) inflight.delete(key);
607
+ }
608
+ function attend(key, flight, signal) {
609
+ flight.callers += 1;
610
+ const leave = () => {
611
+ flight.callers -= 1;
612
+ if (flight.callers > 0) return;
613
+ free(key, flight);
614
+ flight.controller.abort();
615
+ };
616
+ signal.addEventListener("abort", leave, { once: true });
617
+ return flight.promise.finally(() => signal.removeEventListener("abort", leave));
618
+ }
548
619
  return {
549
- run(key, work, onWaiterJoined) {
620
+ run(key, work, callerSignal) {
550
621
  const existing = inflight.get(key);
551
622
  if (existing) {
552
- options.onJoin?.(key);
553
- existing.notifyOwner?.();
554
- return existing.promise;
623
+ try {
624
+ options.onJoin?.(key);
625
+ } catch (err) {
626
+ console.error("[Solve/single-flight] onJoin threw:", err);
627
+ }
628
+ return attend(key, existing, callerSignal);
555
629
  }
630
+ const controller = new AbortController();
556
631
  const flight = {
557
- notifyOwner: onWaiterJoined,
558
- promise: void 0
632
+ controller,
633
+ callers: 0,
634
+ promise: (async () => work(controller.signal))().finally(() => free(key, flight))
559
635
  };
560
- const p = (async () => work())().finally(() => {
561
- flight.notifyOwner = void 0;
562
- if (inflight.get(key) === flight) inflight.delete(key);
563
- });
564
- flight.promise = p;
565
636
  inflight.set(key, flight);
566
- return p;
637
+ return attend(key, flight, callerSignal);
567
638
  },
568
639
  inFlight() {
569
640
  return inflight.size;
570
641
  }
571
642
  };
572
643
  }
644
+ // src/server/transform-input.ts
645
+ /**
646
+ * What `SolveEngine.solve` takes as `inputs`, from a schema the host stored. The bounds live on
647
+ * the number widgets in the layout, not on `schema.inputs`, so without them nothing clamps.
648
+ */
649
+ function solveInputsFromSchema(schema) {
650
+ const bounds = /* @__PURE__ */ new Map();
651
+ for (const item of getInputItems(schema)) if (item.widgetType === "number" && item.config) bounds.set(item.paramId, item.config);
652
+ return schema.inputs.map((input) => {
653
+ const widget = bounds.get(input.id);
654
+ return widget ? {
655
+ ...input,
656
+ minimum: widget.minimum,
657
+ maximum: widget.maximum
658
+ } : input;
659
+ });
660
+ }
661
+ /** The input tree a solve sends: each input's value, or its schema default when it has none. */
662
+ function buildSolveInputTree(inputs, values) {
663
+ return TreeBuilder.fromValues(inputs.filter((input) => input.paramType).map((input) => ({
664
+ paramName: input.nickname,
665
+ paramType: input.paramType,
666
+ value: (Object.hasOwn(values, input.id) ? values[input.id] : void 0) ?? input.default,
667
+ minimum: input.minimum,
668
+ maximum: input.maximum
669
+ })));
670
+ }
671
+ // src/server/solve-pipeline.ts
672
+ /**
673
+ * Runs one solve against a warm scheduler and classifies how it ended: a solved response, or an
674
+ * expected failure as a value instead of a throw. This is the part single-flight shares, so
675
+ * nothing here depends on who is asking.
676
+ */
677
+ function shed(durationMs, reason, message) {
678
+ return {
679
+ kind: "shed",
680
+ durationMs,
681
+ reason,
682
+ retryAfterSeconds: 1,
683
+ message
684
+ };
685
+ }
686
+ /** The solve lost its client, and no caller asked for that: a config write or a shutdown. */
687
+ function interrupted(durationMs) {
688
+ return shed(durationMs, "interrupted", "The solve was interrupted. Retry shortly.");
689
+ }
690
+ function classifyFailure(err, durationMs, args) {
691
+ if (!(err instanceof ComputeError)) return {
692
+ kind: "compute_error",
693
+ durationMs,
694
+ error: err
695
+ };
696
+ if (err.statusCode === 413) {
697
+ const cap = args.client.limits.maxRequestBytes;
698
+ return {
699
+ kind: "request_too_large",
700
+ durationMs,
701
+ message: `The inputs are larger than the compute server accepts` + (cap ? ` (${Math.round(cap / 1048576)} MB)` : "") + ". Its admin can raise RHINO_COMPUTE_MAX_REQUEST_SIZE."
702
+ };
703
+ }
704
+ switch (err.code) {
705
+ case ErrorCodes.TIMEOUT_ERROR: return {
706
+ kind: "timeout",
707
+ durationMs,
708
+ message: `Solve exceeded the ${Math.round(args.client.limits.solveDeadlineMs / 1e3)}s deadline set by the compute server.`
709
+ };
710
+ case ErrorCodes.ABORTED: return args.signal.aborted ? {
711
+ kind: "client_abort",
712
+ durationMs
713
+ } : interrupted(durationMs);
714
+ case ErrorCodes.RESPONSE_TOO_LARGE: return { kind: "too_large" };
715
+ case ErrorCodes.QUEUE_FULL: return shed(durationMs, "queue_full", "Compute server is at capacity. Retry shortly.");
716
+ case ErrorCodes.QUEUE_TIMEOUT: return shed(durationMs, "queue_timeout", "Compute request waited too long in the queue. Retry shortly.");
717
+ default: return {
718
+ kind: "compute_error",
719
+ durationMs,
720
+ error: err
721
+ };
722
+ }
723
+ }
724
+ function defBytesVerdict(source) {
725
+ if (!isByteCacheRef(source)) return void 0;
726
+ const { loaded, fromCache } = source.outcome;
727
+ return !loaded ? "skipped" : fromCache ? "hit" : "miss";
728
+ }
729
+ async function runSolvePipeline(args) {
730
+ let rhino = null;
731
+ const solveStart = performance.now();
732
+ try {
733
+ const detail = await args.client.scheduler.solveDetailed(args.definitionSource, args.inputTree, {
734
+ signal: args.signal,
735
+ selvaevents: args.selvaEvents,
736
+ onServerTiming: (t) => {
737
+ rhino = t;
738
+ }
739
+ });
740
+ const defBytes = defBytesVerdict(args.definitionSource);
741
+ return {
742
+ kind: "solved",
743
+ response: detail.response,
744
+ encoder: createResponseEncoder(detail.response, args.responseMaxBytes),
745
+ solveMs: performance.now() - solveStart,
746
+ rhino,
747
+ settle: {
748
+ fromCache: detail.fromCache,
749
+ definitionReuploaded: detail.definitionReuploaded
750
+ },
751
+ ...defBytes ? { defBytes } : {}
752
+ };
753
+ } catch (err) {
754
+ return classifyFailure(err, performance.now() - solveStart, args);
755
+ }
756
+ }
573
757
  // src/server/solve-engine.ts
574
758
  /**
575
759
  * Facade over the warm-client cache, definition-byte cache, single-flight
576
760
  * coalescing, and the solve pipeline — the primitives an app route would
577
761
  * otherwise wire up by hand.
578
762
  *
579
- * Doesn't read env itself: `@selvajs/server` owns `resolveComputeLimits` and
580
- * depends on `@selvajs/solve`, so calling it from here would be a circular
581
- * import. Callers resolve their own limits and pass in the fields below.
763
+ * Doesn't read env itself: `@selvajs/server` owns `resolveComputeLimits`, and
764
+ * this package stays independent of it. Callers resolve their own limits and
765
+ * pass in the fields below.
582
766
  *
583
767
  * Stays out of auth, DB reads, share tokens, rate limiting, metrics, and
584
768
  * compute-server selection — all app policy, supplied per-call via
585
769
  * `SolveEngineSolveArgs.server`.
586
770
  */
587
- function isByteCacheRef(source) {
588
- return typeof source === "object" && source !== null && !(source instanceof Uint8Array) && "outcome" in source;
589
- }
590
- function isVersionLoadPair(source) {
591
- return typeof source === "object" && source !== null && !(source instanceof Uint8Array) && !("outcome" in source) && !("key" in source) && "versionId" in source;
771
+ /** A deadline of 0 means no deadline, and a `NaN` budget retains without limit, both silently. */
772
+ function assertValidLimits(limits) {
773
+ const check = (name, min) => {
774
+ const value = limits[name];
775
+ if (!Number.isFinite(value) || value < min) throw new RangeError(`SolveEngine: limits.${name} must be a number >= ${min}, got ${value}.`);
776
+ };
777
+ check("computeResponseMaxBytes", 1);
778
+ check("computeMaxQueueDepth", 0);
779
+ check("computeQueueWaitMs", 0);
780
+ check("computeDefinitionCacheBytes", 0);
781
+ check("computeSolveCacheBytes", 0);
592
782
  }
593
783
  var SolveEngine = class {
594
784
  limits;
595
785
  clientCache;
596
786
  byteCache;
597
787
  singleFlight;
788
+ clientGenerations = /* @__PURE__ */ new WeakMap();
789
+ lastClientGeneration = 0;
598
790
  constructor(options) {
791
+ assertValidLimits(options.limits);
599
792
  this.limits = options.limits;
600
- const logger = options.logger ?? new NoopLogger();
601
793
  this.clientCache = createClientCache({
602
- solveDeadlineMs: this.limits.solveDeadlineMs,
603
794
  cachesolve: this.limits.computeServerCachesolve,
604
- reuseServerDefinitionCache: this.limits.computeReuseDefinitionCache,
605
795
  maxQueueDepth: this.limits.computeMaxQueueDepth,
606
796
  queueWaitMs: this.limits.computeQueueWaitMs,
607
797
  responseCacheMaxBytes: this.limits.computeSolveCacheBytes,
608
798
  maxResponseBytes: this.limits.computeResponseMaxBytes,
609
799
  debug: options.debug ?? false,
610
800
  maxWarmComputeServers: options.maxWarmComputeServers,
611
- onDebugLog: options.onDebugLog ?? ((message) => logger.debug(message, { component: "Compute/client-cache" }))
801
+ onDebugLog: options.onDebugLog
612
802
  });
613
803
  this.byteCache = createDefinitionByteCache(this.limits.computeDefinitionCacheBytes);
614
804
  this.singleFlight = createSolveCacheSingleFlight({ onJoin: options.onSolveCoalesced });
@@ -619,6 +809,10 @@ var SolveEngine = class {
619
809
  definitionRef(versionId, load) {
620
810
  return this.byteCache.getOrLoad(versionId, load);
621
811
  }
812
+ /**
813
+ * Drops the warm client for `id`; call it when the server's URL or key changes. Solves already
814
+ * running finish on the old connection details first, so a revoked key stays in use until then.
815
+ */
622
816
  evictServer(id) {
623
817
  this.clientCache.evict(id);
624
818
  }
@@ -640,71 +834,119 @@ var SolveEngine = class {
640
834
  };
641
835
  }
642
836
  async solve(args) {
643
- const { definitionSource: resolvedSource, byteRefOutcome, identity } = this.resolveDefinitionSource(args);
644
- const client = await this.clientCache.getClient(args.server, { definitionGuid: args.definitionGuid });
645
- const inputTree = buildSolveInputTree(args.inputs, args.values);
646
- const coalesceKey = `${identity}:${args.server.id}:${stableStringify(inputTree)}`;
647
- const abortController = new AbortController();
648
- let hasWaiters = false;
649
- const onCallerAbort = () => {
650
- if (!hasWaiters) abortController.abort();
837
+ const { definitionSource, identity } = this.resolveDefinitionSource(args);
838
+ const startedAt = performance.now();
839
+ const elapsed = () => performance.now() - startedAt;
840
+ if (args.signal.aborted) return {
841
+ kind: "client_abort",
842
+ durationMs: 0
651
843
  };
652
- args.signal.addEventListener("abort", onCallerAbort, { once: true });
653
- const acceptEncoding = args.acceptEncoding ?? "";
654
- let outcome;
844
+ let client;
655
845
  try {
656
- outcome = await this.singleFlight.run(coalesceKey, () => runSolvePipeline({
657
- definitionSource: resolvedSource,
658
- byteRefOutcome,
659
- inputs: args.inputs,
660
- values: args.values,
661
- inputTree,
662
- client,
663
- responseMaxBytes: this.limits.computeResponseMaxBytes,
664
- solveDeadlineMs: this.limits.solveDeadlineMs,
665
- acceptEncoding,
666
- selvaEvents: args.selvaEvents,
667
- signal: abortController.signal,
668
- loadStartMs: args.loadStartMs ?? performance.now(),
669
- defLoadMs: args.defLoadMs ?? 0,
670
- prepMarks: args.prepMarks
671
- }), () => {
672
- hasWaiters = true;
673
- });
674
- } finally {
675
- args.signal.removeEventListener("abort", onCallerAbort);
846
+ client = await this.clientCache.getClient(args.server, { definitionGuid: args.definitionGuid });
847
+ } catch (error) {
848
+ if (args.signal.aborted) return {
849
+ kind: "client_abort",
850
+ durationMs: elapsed()
851
+ };
852
+ if (error instanceof ComputeError && error.code === ErrorCodes.ABORTED) return interrupted(elapsed());
853
+ return {
854
+ kind: "compute_error",
855
+ durationMs: elapsed(),
856
+ error
857
+ };
676
858
  }
677
- if (outcome.kind !== "ok") return outcome;
678
- const wire = adaptEnvelopeToEncoding(outcome.envelope, acceptEncoding);
679
- return {
680
- ...outcome,
681
- envelope: {
682
- ...outcome.envelope,
683
- body: wire.body,
684
- headers: wire.headers
685
- }
859
+ if (args.signal.aborted) return {
860
+ kind: "client_abort",
861
+ durationMs: elapsed()
686
862
  };
687
- }
688
- toResponse(outcome, opts) {
689
- const fail = (status, body) => {
690
- if (opts?.onError) opts.onError(status, body);
691
- const headers = { "Content-Type": "application/json" };
692
- if (body.retryAfter !== void 0) headers["Retry-After"] = String(body.retryAfter);
863
+ const treeBuildStart = performance.now();
864
+ let inputTree;
865
+ try {
866
+ inputTree = buildSolveInputTree(args.inputs, args.values);
867
+ } catch (error) {
868
+ return {
869
+ kind: "compute_error",
870
+ durationMs: elapsed(),
871
+ error
872
+ };
873
+ }
874
+ const treeBuildMs = performance.now() - treeBuildStart;
875
+ const flight = await this.singleFlight.run(`${identity}:${args.server.id}:${this.clientGeneration(client)}:${stableStringify(inputTree)}`, (signal) => runSolvePipeline({
876
+ definitionSource,
877
+ inputTree,
878
+ client,
879
+ responseMaxBytes: this.limits.computeResponseMaxBytes,
880
+ selvaEvents: args.selvaEvents,
881
+ signal
882
+ }), args.signal);
883
+ if (args.signal.aborted) return {
884
+ kind: "client_abort",
885
+ durationMs: elapsed()
886
+ };
887
+ if (flight.kind !== "solved") return flight;
888
+ let encoded;
889
+ try {
890
+ encoded = await flight.encoder.encode(args.acceptEncoding ?? "");
891
+ } catch (error) {
693
892
  return {
694
- status,
695
- headers,
696
- body: JSON.stringify(body)
893
+ kind: "compute_error",
894
+ durationMs: elapsed(),
895
+ error
697
896
  };
897
+ }
898
+ if (encoded === "too_large") return { kind: "too_large" };
899
+ const { body, serializedBytes, serializeMs, gzipMs } = encoded;
900
+ const { solveMs, response } = flight;
901
+ const serverTotalMs = performance.now() - (args.loadStartMs ?? startedAt);
902
+ const timing = {
903
+ defLoadMs: args.defLoadMs ?? 0,
904
+ treeBuildMs,
905
+ solveMs,
906
+ serializeMs,
907
+ gzipMs,
908
+ serverTotalMs,
909
+ rhino: flight.rhino,
910
+ settle: flight.settle,
911
+ ...flight.defBytes ? { defBytes: flight.defBytes } : {}
698
912
  };
913
+ const serverTiming = buildServerTiming({
914
+ ...timing,
915
+ prepMarks: args.prepMarks ?? []
916
+ });
917
+ return {
918
+ kind: "ok",
919
+ envelope: buildEnvelope(encoded, response, serverTiming, {
920
+ ...timing,
921
+ serializedBytes,
922
+ compressedBytes: encoded.encoding === "gzip" ? body.byteLength : null
923
+ }),
924
+ solveMs,
925
+ errorCount: response.errors?.length ?? 0,
926
+ warningCount: response.warnings?.length ?? 0
927
+ };
928
+ }
929
+ /**
930
+ * The response for an outcome; the mapping from outcome to status lives here. Throws
931
+ * `compute_error`'s error: what it means for the caller is the host's to decide.
932
+ */
933
+ toWebResponse(outcome) {
934
+ const fail = (status, body) => new Response(JSON.stringify(body), {
935
+ status,
936
+ headers: {
937
+ "Content-Type": "application/json",
938
+ ...body.retryAfter !== void 0 ? { "Retry-After": String(body.retryAfter) } : {}
939
+ }
940
+ });
699
941
  switch (outcome.kind) {
700
- case "ok": return {
942
+ case "ok": return new Response(outcome.envelope.body, {
701
943
  status: 200,
702
- headers: outcome.envelope.headers,
703
- body: outcome.envelope.body
704
- };
944
+ headers: outcome.envelope.headers
945
+ });
705
946
  case "timeout": return fail(504, { message: outcome.message });
706
947
  case "client_abort": return fail(499, { message: "Client closed request" });
707
948
  case "too_large": return fail(413, { message: "Solve result is too large to return. This usually means a file output exceeds the supported size." });
949
+ case "request_too_large": return fail(413, { message: outcome.message });
708
950
  case "shed": return fail(503, {
709
951
  message: outcome.message,
710
952
  retryAfter: outcome.retryAfterSeconds
@@ -712,29 +954,21 @@ var SolveEngine = class {
712
954
  case "compute_error": throw outcome.error;
713
955
  }
714
956
  }
715
- toWebResponse(outcome) {
716
- const r = this.toResponse(outcome);
717
- return new Response(typeof r.body === "string" ? r.body : new Uint8Array(r.body), {
718
- status: r.status,
719
- headers: r.headers
720
- });
957
+ /**
958
+ * Part of the coalesce key: after an eviction the id stays and the client changes, and a new
959
+ * caller joining a draining flight would get an answer from the old URL or key.
960
+ */
961
+ clientGeneration(client) {
962
+ let generation = this.clientGenerations.get(client);
963
+ if (generation === void 0) {
964
+ generation = ++this.lastClientGeneration;
965
+ this.clientGenerations.set(client, generation);
966
+ }
967
+ return generation;
721
968
  }
722
969
  resolveDefinitionSource(args) {
723
970
  const source = args.definitionSource;
724
- if (isVersionLoadPair(source)) {
725
- const ref = this.byteCache.getOrLoad(source.versionId, source.load);
726
- return {
727
- definitionSource: ref,
728
- byteRefOutcome: ref.outcome,
729
- identity: ref.key
730
- };
731
- }
732
- if (isByteCacheRef(source)) return {
733
- definitionSource: source,
734
- byteRefOutcome: source.outcome,
735
- identity: source.key
736
- };
737
- if (isDefinitionRef$1(source)) return {
971
+ if (isByteCacheRef(source) || isDefinitionRef(source)) return {
738
972
  definitionSource: source,
739
973
  identity: source.key
740
974
  };
@@ -745,6 +979,6 @@ var SolveEngine = class {
745
979
  };
746
980
  }
747
981
  };
748
- export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, SolveEngine, adaptEnvelopeToEncoding, buildSolveInputTree, createClientCache, createDefinitionByteCache, createSolveCacheSingleFlight, isDefinitionRef, runSolvePipeline, serverIdentity };
982
+ export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, DEFAULT_SOLVE_DEADLINE_MS, MAX_SOLVE_DEADLINE_MS, SolveEngine, acceptsGzip, solveInputsFromSchema };
749
983
 
750
984
  //# sourceMappingURL=server.js.map