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

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
52
  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
- 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,6 +69,7 @@ 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. */
@@ -261,24 +77,8 @@ function fetchForDeadline(deadlineMs) {
261
77
  return deadlineMs >= UNDICI_DEFAULT_LIMIT_MS ? unlimitedFetch : void 0;
262
78
  }
263
79
  // src/server/client-cache.ts
264
- function serverIdentity(server) {
265
- return server.id;
266
- }
267
80
  const DEFAULT_MAX_WARM_COMPUTE_SERVERS = 16;
268
81
  /**
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.
272
- */
273
- const FALLBACK_CONCURRENCY = 1;
274
- /**
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.
279
- */
280
- const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
281
- /**
282
82
  * How long a failed build is remembered before the next `getClient` retries the
283
83
  * handshake. Without this, every solve against a down server pays the full probe
284
84
  * ladder again, so a user clicking Solve twice waits twice.
@@ -288,15 +88,37 @@ const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
288
88
  * out a long penalty window before the app notices.
289
89
  */
290
90
  const FAILED_BUILD_TTL_MS = 5e3;
91
+ function describeSettledSolve(result) {
92
+ const lines = [`[Compute/selva-cache] ${result.fromCache ? "HIT — served from cache (no compute call)" : "miss — went to Rhino.Compute"} (${Math.round(result.durationMs)}ms)`];
93
+ if (result.fromCache) return lines;
94
+ 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");
95
+ const errors = result.response?.errors;
96
+ if (Array.isArray(errors) && errors.length > 0) lines.push(`[Compute/rhino-cache] ${errors.length} GH error(s); not cached by Rhino.Compute.`);
97
+ return lines;
98
+ }
291
99
  function createClientCache(config) {
292
100
  const maxWarmComputeServers = config.maxWarmComputeServers ?? DEFAULT_MAX_WARM_COMPUTE_SERVERS;
293
101
  const cache = /* @__PURE__ */ new Map();
294
102
  const pending = /* @__PURE__ */ new Map();
295
103
  const failed = /* @__PURE__ */ new Map();
104
+ const draining = /* @__PURE__ */ new Set();
296
105
  if (config.debug === "verbose") enableDebugLogging();
297
106
  const debugLog = (message) => {
298
107
  if (config.debug) config.onDebugLog?.(message);
299
108
  };
109
+ function isIdle({ scheduler }) {
110
+ return scheduler.inFlightCount === 0 && scheduler.queueDepth === 0;
111
+ }
112
+ /** Disposing a busy scheduler aborts its solves, so a config write would fail them all. */
113
+ function retire(entry) {
114
+ if (isIdle(entry)) entry.scheduler.dispose();
115
+ else draining.add(entry);
116
+ }
117
+ function finishDraining(entry) {
118
+ if (!draining.has(entry) || !isIdle(entry)) return;
119
+ draining.delete(entry);
120
+ entry.scheduler.dispose();
121
+ }
300
122
  async function build(server, definitionGuid) {
301
123
  const debugVerbose = config.debug === "verbose";
302
124
  const longFetch = fetchForDeadline(config.solveDeadlineMs);
@@ -323,60 +145,28 @@ function createClientCache(config) {
323
145
  };
324
146
  if (definitionGuid) clientConfig.headers = { "X-Selva-Definition": definitionGuid };
325
147
  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();
148
+ const probe = createConcurrencyProbe(client, debugLog);
149
+ await probe.refresh();
355
150
  const scheduler = client.createScheduler({
356
151
  mode: "queue",
357
- maxConcurrent,
152
+ maxConcurrent: probe.maxConcurrent,
358
153
  maxQueueDepth: config.maxQueueDepth || void 0,
359
154
  queueWaitMs: config.queueWaitMs || void 0,
360
155
  timeoutMs: config.solveDeadlineMs,
361
156
  cache: config.responseCacheMaxBytes > 0 ? { maxBytes: config.responseCacheMaxBytes } : false,
362
157
  reuseServerDefinitionCache: config.reuseServerDefinitionCache,
363
158
  onSettle: (_ctx, result) => {
159
+ queueMicrotask(() => finishDraining(entry));
364
160
  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])}`);
373
- }
161
+ if (result.fromCache !== true) probe.refreshIfDue(scheduler);
162
+ if (config.debug) describeSettledSolve(result).forEach(debugLog);
374
163
  }
375
164
  });
376
- return {
165
+ const entry = {
377
166
  client,
378
167
  scheduler
379
168
  };
169
+ return entry;
380
170
  }
381
171
  return {
382
172
  async getClient(server, opts) {
@@ -388,7 +178,7 @@ function createClientCache(config) {
388
178
  return existing;
389
179
  }
390
180
  const inFlight = pending.get(key);
391
- if (inFlight) return inFlight;
181
+ if (inFlight) return inFlight.promise;
392
182
  const recentFailure = failed.get(key);
393
183
  if (recentFailure) {
394
184
  if (Date.now() - recentFailure.at < FAILED_BUILD_TTL_MS) {
@@ -398,39 +188,54 @@ function createClientCache(config) {
398
188
  failed.delete(key);
399
189
  }
400
190
  debugLog(`[Compute/client-cache] miss — building warm client for server ${key}`);
401
- const buildPromise = (async () => {
191
+ const token = { stale: false };
192
+ const promise = (async () => {
402
193
  let entry;
403
194
  try {
404
195
  entry = await build(server, opts?.definitionGuid);
405
196
  } catch (err) {
406
- failed.set(key, {
197
+ if (!token.stale) failed.set(key, {
407
198
  error: err,
408
199
  at: Date.now()
409
200
  });
410
201
  throw err;
411
202
  }
203
+ if (token.stale) {
204
+ entry.scheduler.dispose();
205
+ throw new ComputeError(`Compute server ${key} changed while connecting.`, ErrorCodes.ABORTED);
206
+ }
412
207
  if (cache.size >= maxWarmComputeServers) {
413
208
  const oldestKey = cache.keys().next().value;
414
209
  if (oldestKey !== void 0) {
415
210
  debugLog(`[Compute/client-cache] LRU evicted warm client for server ${oldestKey} (cap ${maxWarmComputeServers})`);
416
- cache.get(oldestKey)?.scheduler.dispose();
211
+ const oldest = cache.get(oldestKey);
417
212
  cache.delete(oldestKey);
213
+ if (oldest) retire(oldest);
418
214
  }
419
215
  }
420
216
  cache.set(key, entry);
421
217
  return entry;
422
- })().finally(() => pending.delete(key));
423
- pending.set(key, buildPromise);
424
- return buildPromise;
218
+ })().finally(() => {
219
+ if (pending.get(key)?.token === token) pending.delete(key);
220
+ });
221
+ pending.set(key, {
222
+ promise,
223
+ token
224
+ });
225
+ return promise;
425
226
  },
426
227
  evict(id) {
427
- const key = id;
428
- failed.delete(key);
429
- const entry = cache.get(key);
228
+ failed.delete(id);
229
+ const build = pending.get(id);
230
+ if (build) {
231
+ build.token.stale = true;
232
+ pending.delete(id);
233
+ }
234
+ const entry = cache.get(id);
430
235
  if (entry) {
431
- debugLog(`[Compute/client-cache] evicted warm client for server ${key} (config change)`);
432
- entry.scheduler.dispose();
433
- cache.delete(key);
236
+ debugLog(`[Compute/client-cache] evicted warm client for server ${id} (config change)`);
237
+ cache.delete(id);
238
+ retire(entry);
434
239
  }
435
240
  },
436
241
  solveCacheStats() {
@@ -458,48 +263,99 @@ function createClientCache(config) {
458
263
  },
459
264
  disposeAll() {
460
265
  debugLog(`[Compute/client-cache] disposing all ${cache.size} warm client(s)`);
461
- for (const entry of cache.values()) entry.scheduler.dispose();
266
+ for (const entry of [...cache.values(), ...draining]) entry.scheduler.dispose();
267
+ for (const build of pending.values()) build.token.stale = true;
268
+ pending.clear();
462
269
  cache.clear();
270
+ draining.clear();
463
271
  failed.clear();
464
272
  }
465
273
  };
466
274
  }
467
275
  // src/server/definition-byte-cache.ts
468
276
  /**
277
+ * In-process cache of `.gh` definition bytes, keyed by immutable **version id**.
278
+ *
279
+ * Turns an eager `storage.get(fileKey)` on every solve request into a lazy
280
+ * `DefinitionRef.load()`: the scheduler calls `load()` only when an upload is
281
+ * unavoidable (a pointer-known re-solve never touches storage or this cache).
282
+ *
283
+ * The key MUST be the version id, never a `fileKey` — a delete-latest-then-
284
+ * reupload can reuse a `fileKey` for different content, which would silently
285
+ * serve one version's bytes for another with no diagnostic. Callers pass
286
+ * `version.id`, and the returned `DefinitionRef.key` is that same id, so the
287
+ * scheduler's own result/pointer cache keys on the same immutable identity.
288
+ *
289
+ * Eviction is LRU by total byte budget, not entry count — definitions range
290
+ * from tens of KB to hundreds of MB, so a count cap would either pin
291
+ * gigabytes or evict uselessly. `COMPUTE_DEFINITION_CACHE_MB` sets the
292
+ * budget (see `createDefinitionByteCache`). No TTL: version ids are
293
+ * immutable, so a cached entry can never go stale. An entry larger than the
294
+ * whole budget is served but never retained.
295
+ */
296
+ /** A `ByteCacheRef` carries `outcome`, which a plain `DefinitionRef` never has. */
297
+ function isByteCacheRef(source) {
298
+ return typeof source === "object" && source !== null && "outcome" in source;
299
+ }
300
+ /**
469
301
  * @param maxBytes total retained-byte budget; `0` (or negative) disables caching
470
302
  * entirely — every `load()` calls the loader and nothing is retained.
471
303
  */
472
304
  function createDefinitionByteCache(maxBytes) {
473
305
  const budget = Math.max(0, Math.floor(maxBytes));
474
306
  const cache = /* @__PURE__ */ new Map();
307
+ const loading = /* @__PURE__ */ new Map();
475
308
  let retainedBytes = 0;
476
309
  const stats = {
477
310
  hits: 0,
478
311
  misses: 0,
479
312
  evictions: 0
480
313
  };
481
- function touch(key, entry) {
482
- cache.delete(key);
483
- cache.set(key, entry);
484
- }
485
314
  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;
315
+ for (const [key, bytes] of cache) {
316
+ if (retainedBytes <= budget) break;
317
+ cache.delete(key);
318
+ retainedBytes -= bytes.byteLength;
491
319
  stats.evictions += 1;
492
320
  }
493
321
  }
494
322
  function store(key, bytes) {
495
323
  if (budget === 0) return;
496
324
  if (bytes.byteLength > budget) return;
497
- const existing = cache.get(key);
498
- if (existing) retainedBytes -= existing.bytes.byteLength;
499
- cache.set(key, { bytes });
325
+ cache.set(key, bytes);
500
326
  retainedBytes += bytes.byteLength;
501
327
  evictToFit();
502
328
  }
329
+ function startLoad(key, load) {
330
+ const controller = new AbortController();
331
+ const entry = {
332
+ controller,
333
+ waiters: 0,
334
+ promise: (async () => {
335
+ const bytes = await load(controller.signal);
336
+ store(key, bytes);
337
+ return bytes;
338
+ })().finally(() => {
339
+ if (loading.get(key) === entry) loading.delete(key);
340
+ })
341
+ };
342
+ loading.set(key, entry);
343
+ return entry;
344
+ }
345
+ /**
346
+ * A caller gone stops waiting, but the read runs on for whoever else is on it. A caller with no
347
+ * signal can't leave, so it stays counted and the read is never aborted under it.
348
+ */
349
+ async function wait(entry, signal) {
350
+ entry.waiters += 1;
351
+ if (!signal) return entry.promise;
352
+ try {
353
+ return await raceAbort(entry.promise, signal);
354
+ } finally {
355
+ entry.waiters -= 1;
356
+ if (entry.waiters === 0 && signal.aborted) entry.controller.abort();
357
+ }
358
+ }
503
359
  return {
504
360
  getOrLoad(versionId, load) {
505
361
  const outcome = {
@@ -509,21 +365,29 @@ function createDefinitionByteCache(maxBytes) {
509
365
  return {
510
366
  key: versionId,
511
367
  outcome,
512
- load: async () => {
368
+ load: async function read(signal) {
369
+ outcome.loaded = true;
513
370
  const hit = cache.get(versionId);
514
371
  if (hit) {
515
- touch(versionId, hit);
372
+ cache.delete(versionId);
373
+ cache.set(versionId, hit);
374
+ stats.hits += 1;
375
+ outcome.fromCache = true;
376
+ return hit;
377
+ }
378
+ const running = loading.get(versionId);
379
+ if (running?.controller.signal.aborted) {
380
+ const woundDown = running.promise.then(() => void 0, () => void 0);
381
+ await (signal ? raceAbort(woundDown, signal) : woundDown);
382
+ return read(signal);
383
+ }
384
+ if (running) {
516
385
  stats.hits += 1;
517
- outcome.loaded = true;
518
386
  outcome.fromCache = true;
519
- return hit.bytes;
387
+ return wait(running, signal);
520
388
  }
521
389
  stats.misses += 1;
522
- outcome.loaded = true;
523
- outcome.fromCache = false;
524
- const bytes = await load();
525
- store(versionId, bytes);
526
- return bytes;
390
+ return wait(startLoad(versionId, load), signal);
527
391
  }
528
392
  };
529
393
  },
@@ -542,62 +406,329 @@ function createDefinitionByteCache(maxBytes) {
542
406
  }
543
407
  };
544
408
  }
409
+ // src/server/response-encoding.ts
410
+ /**
411
+ * Turns one solve's response into each caller's wire form. Runs after single-flight, not inside
412
+ * it: coalesced callers can want different encodings, and each reports its own timing.
413
+ */
414
+ const gzipAsync = promisify(gzip);
415
+ /**
416
+ * Display output is base64 of already-deflated bytes, where higher levels find nothing more:
417
+ * level 1 lands within 1% of the default's size, in up to a fifth of the time.
418
+ */
419
+ const GZIP_LEVEL = 1;
420
+ /** Below this gzip isn't worth the CPU for the wire savings. */
421
+ const GZIP_MIN_BYTES = 1024;
422
+ /** Compute-response contract version. Bump (and document the change) whenever the envelope's shape changes in a way a consumer could observe. */
423
+ const COMPUTE_CONTRACT_VERSION = 1;
424
+ const COMPUTE_VERSION_HEADER = "X-Selva-Compute-Version";
425
+ function buildEnvelope(encoded, result, serverTiming, metrics) {
426
+ const headers = {
427
+ "Content-Type": "application/json",
428
+ "Server-Timing": serverTiming,
429
+ [COMPUTE_VERSION_HEADER]: String(1),
430
+ Vary: "Accept-Encoding"
431
+ };
432
+ const { body, encoding } = encoded;
433
+ if (encoding === "gzip") headers["Content-Encoding"] = "gzip";
434
+ headers["Content-Length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength);
435
+ return {
436
+ body,
437
+ ...encoding ? { encoding } : {},
438
+ headers,
439
+ result,
440
+ metrics
441
+ };
442
+ }
443
+ /** Whether an `Accept-Encoding` header takes gzip. `gzip;q=0` is a client refusing it. */
444
+ function acceptsGzip(acceptEncoding) {
445
+ return acceptEncoding.split(",").some((part) => {
446
+ const [coding, ...params] = part.split(";").map((s) => s.trim().toLowerCase());
447
+ if (coding !== "gzip") return false;
448
+ const q = params.find((p) => p.startsWith("q="));
449
+ return q === void 0 || Number(q.slice(2)) > 0;
450
+ });
451
+ }
452
+ /**
453
+ * A scheduler cache hit returns the same response object, so a repeat solve reuses its bytes rather
454
+ * than paying to serialize and compress them again. Entries live exactly as long as the response.
455
+ * Only the gzip form is kept: browsers always take it, and the JSON string would double the memory.
456
+ * Holds the promise, so callers arriving during the compression share it.
457
+ */
458
+ const gzippedBodies = /* @__PURE__ */ new WeakMap();
459
+ function gzipOnce(response, json, serializedBytes) {
460
+ const pending = gzipAsync(Buffer.from(json), { level: GZIP_LEVEL }).then((body) => ({
461
+ body,
462
+ serializedBytes
463
+ }));
464
+ gzippedBodies.set(response, pending);
465
+ pending.catch(() => gzippedBodies.delete(response));
466
+ return pending;
467
+ }
468
+ /**
469
+ * One per solve. The JSON is kept here rather than beside the gzip body: it only has to outlive the
470
+ * callers of this flight, who would otherwise each stringify the same response and hold a copy.
471
+ */
472
+ function createResponseEncoder(response, maxBytes) {
473
+ let serialized;
474
+ function serialize() {
475
+ if (serialized !== void 0) return serialized;
476
+ try {
477
+ const json = JSON.stringify(response);
478
+ const bytes = Buffer.byteLength(json);
479
+ serialized = bytes > maxBytes ? "too_large" : {
480
+ json,
481
+ bytes
482
+ };
483
+ } catch (err) {
484
+ if (!(err instanceof RangeError)) throw err;
485
+ serialized = "too_large";
486
+ }
487
+ return serialized;
488
+ }
489
+ return { async encode(acceptEncoding) {
490
+ const wantsGzip = acceptsGzip(acceptEncoding);
491
+ let gzipped = wantsGzip ? gzippedBodies.get(response) : void 0;
492
+ let serializeMs = 0;
493
+ if (!gzipped) {
494
+ const serializeStart = performance.now();
495
+ const plain = serialize();
496
+ serializeMs = performance.now() - serializeStart;
497
+ if (plain === "too_large") {
498
+ if (wantsGzip) gzippedBodies.set(response, Promise.resolve("too_large"));
499
+ return "too_large";
500
+ }
501
+ if (!wantsGzip || plain.bytes <= GZIP_MIN_BYTES) return {
502
+ body: plain.json,
503
+ serializedBytes: plain.bytes,
504
+ serializeMs,
505
+ gzipMs: 0
506
+ };
507
+ gzipped = gzipOnce(response, plain.json, plain.bytes);
508
+ }
509
+ const gzipStart = performance.now();
510
+ const verdict = await gzipped;
511
+ if (verdict === "too_large") return verdict;
512
+ return {
513
+ body: verdict.body,
514
+ encoding: "gzip",
515
+ serializedBytes: verdict.serializedBytes,
516
+ serializeMs,
517
+ gzipMs: performance.now() - gzipStart
518
+ };
519
+ } };
520
+ }
521
+ // src/server/server-timing.ts
522
+ /**
523
+ * `total` is the server's headers-to-out wall time, so browser `ttfb − total`
524
+ * ≈ network+send latency. When the compute server reports its own timing
525
+ * (VektorNode fork), `rhino_*` is time on the compute server and `compute_link`
526
+ * is everything between it (network + queue wait). `rhino_cache` is 1 when the
527
+ * compute server answered from its own result cache.
528
+ */
529
+ function buildServerTiming(parts) {
530
+ 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)}`;
531
+ const rhino = parts.rhino;
532
+ if (rhino) {
533
+ const decode = rhino.decode ?? 0;
534
+ const solve = rhino.solve ?? 0;
535
+ const encode = rhino.encode ?? 0;
536
+ const computeLinkMs = Math.max(0, parts.solveMs - decode - solve - encode);
537
+ 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}`;
538
+ if (rhino.gh !== void 0) header += `, rhino_gh;dur=${rhino.gh.toFixed(1)}`;
539
+ if (rhino.recomputed) header += `, rhino_recomputed;desc="${rhino.recomputed.ran}/${rhino.recomputed.total}"`;
540
+ }
541
+ for (const [label, ms] of parts.prepMarks) header += `, p_${label};dur=${ms.toFixed(1)}`;
542
+ const settle = parts.settle;
543
+ header += `, selva_cache;dur=${settle.fromCache ? 1 : 0}`;
544
+ if (settle.definitionReuploaded !== void 0) header += `, def_reupload;dur=${settle.definitionReuploaded ? 1 : 0}`;
545
+ if (parts.defBytes) header += `, def_bytes;desc=${parts.defBytes}`;
546
+ return header;
547
+ }
545
548
  // src/server/solve-cache-single-flight.ts
546
549
  function createSolveCacheSingleFlight(options = {}) {
547
550
  const inflight = /* @__PURE__ */ new Map();
551
+ function free(key, flight) {
552
+ if (inflight.get(key) === flight) inflight.delete(key);
553
+ }
554
+ function attend(key, flight, signal) {
555
+ flight.callers += 1;
556
+ const leave = () => {
557
+ flight.callers -= 1;
558
+ if (flight.callers > 0) return;
559
+ free(key, flight);
560
+ flight.controller.abort();
561
+ };
562
+ signal.addEventListener("abort", leave, { once: true });
563
+ return flight.promise.finally(() => signal.removeEventListener("abort", leave));
564
+ }
548
565
  return {
549
- run(key, work, onWaiterJoined) {
566
+ run(key, work, callerSignal) {
550
567
  const existing = inflight.get(key);
551
568
  if (existing) {
552
- options.onJoin?.(key);
553
- existing.notifyOwner?.();
554
- return existing.promise;
569
+ try {
570
+ options.onJoin?.(key);
571
+ } catch (err) {
572
+ console.error("[Solve/single-flight] onJoin threw:", err);
573
+ }
574
+ return attend(key, existing, callerSignal);
555
575
  }
576
+ const controller = new AbortController();
556
577
  const flight = {
557
- notifyOwner: onWaiterJoined,
558
- promise: void 0
578
+ controller,
579
+ callers: 0,
580
+ promise: (async () => work(controller.signal))().finally(() => free(key, flight))
559
581
  };
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
582
  inflight.set(key, flight);
566
- return p;
583
+ return attend(key, flight, callerSignal);
567
584
  },
568
585
  inFlight() {
569
586
  return inflight.size;
570
587
  }
571
588
  };
572
589
  }
590
+ // src/server/transform-input.ts
591
+ /**
592
+ * What `SolveEngine.solve` takes as `inputs`, from a schema the host stored. The bounds live on
593
+ * the number widgets in the layout, not on `schema.inputs`, so without them nothing clamps.
594
+ */
595
+ function solveInputsFromSchema(schema) {
596
+ const bounds = /* @__PURE__ */ new Map();
597
+ for (const item of getInputItems(schema)) if (item.widgetType === "number" && item.config) bounds.set(item.paramId, item.config);
598
+ return schema.inputs.map((input) => {
599
+ const widget = bounds.get(input.id);
600
+ return widget ? {
601
+ ...input,
602
+ minimum: widget.minimum,
603
+ maximum: widget.maximum
604
+ } : input;
605
+ });
606
+ }
607
+ /** The input tree a solve sends: each input's value, or its schema default when it has none. */
608
+ function buildSolveInputTree(inputs, values) {
609
+ return TreeBuilder.fromValues(inputs.filter((input) => input.paramType).map((input) => ({
610
+ paramName: input.nickname,
611
+ paramType: input.paramType,
612
+ value: (Object.hasOwn(values, input.id) ? values[input.id] : void 0) ?? input.default,
613
+ minimum: input.minimum,
614
+ maximum: input.maximum
615
+ })));
616
+ }
617
+ // src/server/solve-pipeline.ts
618
+ /**
619
+ * Runs one solve against a warm scheduler and classifies how it ended: a solved response, or an
620
+ * expected failure as a value instead of a throw. This is the part single-flight shares, so
621
+ * nothing here depends on who is asking.
622
+ */
623
+ function shed(durationMs, reason, message) {
624
+ return {
625
+ kind: "shed",
626
+ durationMs,
627
+ reason,
628
+ retryAfterSeconds: 1,
629
+ message
630
+ };
631
+ }
632
+ /** The solve lost its client, and no caller asked for that: a config write or a shutdown. */
633
+ function interrupted(durationMs) {
634
+ return shed(durationMs, "interrupted", "The solve was interrupted. Retry shortly.");
635
+ }
636
+ function classifyFailure(err, durationMs, args) {
637
+ if (!(err instanceof ComputeError)) return {
638
+ kind: "compute_error",
639
+ durationMs,
640
+ error: err
641
+ };
642
+ switch (err.code) {
643
+ case ErrorCodes.TIMEOUT_ERROR: return {
644
+ kind: "timeout",
645
+ durationMs,
646
+ message: `Solve exceeded the ${Math.round(args.solveDeadlineMs / 1e3)}s deadline.`
647
+ };
648
+ case ErrorCodes.ABORTED: return args.signal.aborted ? {
649
+ kind: "client_abort",
650
+ durationMs
651
+ } : interrupted(durationMs);
652
+ case ErrorCodes.RESPONSE_TOO_LARGE: return { kind: "too_large" };
653
+ case ErrorCodes.QUEUE_FULL: return shed(durationMs, "queue_full", "Compute server is at capacity. Retry shortly.");
654
+ case ErrorCodes.QUEUE_TIMEOUT: return shed(durationMs, "queue_timeout", "Compute request waited too long in the queue. Retry shortly.");
655
+ default: return {
656
+ kind: "compute_error",
657
+ durationMs,
658
+ error: err
659
+ };
660
+ }
661
+ }
662
+ function defBytesVerdict(source) {
663
+ if (!isByteCacheRef(source)) return void 0;
664
+ const { loaded, fromCache } = source.outcome;
665
+ return !loaded ? "skipped" : fromCache ? "hit" : "miss";
666
+ }
667
+ async function runSolvePipeline(args) {
668
+ let rhino = null;
669
+ const solveStart = performance.now();
670
+ try {
671
+ const detail = await args.client.scheduler.solveDetailed(args.definitionSource, args.inputTree, {
672
+ signal: args.signal,
673
+ selvaevents: args.selvaEvents,
674
+ onServerTiming: (t) => {
675
+ rhino = t;
676
+ }
677
+ });
678
+ const defBytes = defBytesVerdict(args.definitionSource);
679
+ return {
680
+ kind: "solved",
681
+ response: detail.response,
682
+ encoder: createResponseEncoder(detail.response, args.responseMaxBytes),
683
+ solveMs: performance.now() - solveStart,
684
+ rhino,
685
+ settle: {
686
+ fromCache: detail.fromCache,
687
+ definitionReuploaded: detail.definitionReuploaded
688
+ },
689
+ ...defBytes ? { defBytes } : {}
690
+ };
691
+ } catch (err) {
692
+ return classifyFailure(err, performance.now() - solveStart, args);
693
+ }
694
+ }
573
695
  // src/server/solve-engine.ts
574
696
  /**
575
697
  * Facade over the warm-client cache, definition-byte cache, single-flight
576
698
  * coalescing, and the solve pipeline — the primitives an app route would
577
699
  * otherwise wire up by hand.
578
700
  *
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.
701
+ * Doesn't read env itself: `@selvajs/server` owns `resolveComputeLimits`, and
702
+ * this package stays independent of it. Callers resolve their own limits and
703
+ * pass in the fields below.
582
704
  *
583
705
  * Stays out of auth, DB reads, share tokens, rate limiting, metrics, and
584
706
  * compute-server selection — all app policy, supplied per-call via
585
707
  * `SolveEngineSolveArgs.server`.
586
708
  */
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;
709
+ /** A deadline of 0 means no deadline, and a `NaN` budget retains without limit, both silently. */
710
+ function assertValidLimits(limits) {
711
+ const check = (name, min) => {
712
+ const value = limits[name];
713
+ if (!Number.isFinite(value) || value < min) throw new RangeError(`SolveEngine: limits.${name} must be a number >= ${min}, got ${value}.`);
714
+ };
715
+ check("solveDeadlineMs", 1);
716
+ check("computeResponseMaxBytes", 1);
717
+ check("computeMaxQueueDepth", 0);
718
+ check("computeQueueWaitMs", 0);
719
+ check("computeDefinitionCacheBytes", 0);
720
+ check("computeSolveCacheBytes", 0);
592
721
  }
593
722
  var SolveEngine = class {
594
723
  limits;
595
724
  clientCache;
596
725
  byteCache;
597
726
  singleFlight;
727
+ clientGenerations = /* @__PURE__ */ new WeakMap();
728
+ lastClientGeneration = 0;
598
729
  constructor(options) {
730
+ assertValidLimits(options.limits);
599
731
  this.limits = options.limits;
600
- const logger = options.logger ?? new NoopLogger();
601
732
  this.clientCache = createClientCache({
602
733
  solveDeadlineMs: this.limits.solveDeadlineMs,
603
734
  cachesolve: this.limits.computeServerCachesolve,
@@ -608,7 +739,7 @@ var SolveEngine = class {
608
739
  maxResponseBytes: this.limits.computeResponseMaxBytes,
609
740
  debug: options.debug ?? false,
610
741
  maxWarmComputeServers: options.maxWarmComputeServers,
611
- onDebugLog: options.onDebugLog ?? ((message) => logger.debug(message, { component: "Compute/client-cache" }))
742
+ onDebugLog: options.onDebugLog
612
743
  });
613
744
  this.byteCache = createDefinitionByteCache(this.limits.computeDefinitionCacheBytes);
614
745
  this.singleFlight = createSolveCacheSingleFlight({ onJoin: options.onSolveCoalesced });
@@ -619,6 +750,10 @@ var SolveEngine = class {
619
750
  definitionRef(versionId, load) {
620
751
  return this.byteCache.getOrLoad(versionId, load);
621
752
  }
753
+ /**
754
+ * Drops the warm client for `id`; call it when the server's URL or key changes. Solves already
755
+ * running finish on the old connection details first, so a revoked key stays in use until then.
756
+ */
622
757
  evictServer(id) {
623
758
  this.clientCache.evict(id);
624
759
  }
@@ -640,68 +775,116 @@ var SolveEngine = class {
640
775
  };
641
776
  }
642
777
  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();
778
+ const { definitionSource, identity } = this.resolveDefinitionSource(args);
779
+ const startedAt = performance.now();
780
+ const elapsed = () => performance.now() - startedAt;
781
+ if (args.signal.aborted) return {
782
+ kind: "client_abort",
783
+ durationMs: 0
651
784
  };
652
- args.signal.addEventListener("abort", onCallerAbort, { once: true });
653
- const acceptEncoding = args.acceptEncoding ?? "";
654
- let outcome;
785
+ let client;
655
786
  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);
787
+ client = await this.clientCache.getClient(args.server, { definitionGuid: args.definitionGuid });
788
+ } catch (error) {
789
+ if (args.signal.aborted) return {
790
+ kind: "client_abort",
791
+ durationMs: elapsed()
792
+ };
793
+ if (error instanceof ComputeError && error.code === ErrorCodes.ABORTED) return interrupted(elapsed());
794
+ return {
795
+ kind: "compute_error",
796
+ durationMs: elapsed(),
797
+ error
798
+ };
676
799
  }
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
- }
800
+ if (args.signal.aborted) return {
801
+ kind: "client_abort",
802
+ durationMs: elapsed()
686
803
  };
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);
804
+ const treeBuildStart = performance.now();
805
+ let inputTree;
806
+ try {
807
+ inputTree = buildSolveInputTree(args.inputs, args.values);
808
+ } catch (error) {
693
809
  return {
694
- status,
695
- headers,
696
- body: JSON.stringify(body)
810
+ kind: "compute_error",
811
+ durationMs: elapsed(),
812
+ error
697
813
  };
814
+ }
815
+ const treeBuildMs = performance.now() - treeBuildStart;
816
+ const flight = await this.singleFlight.run(`${identity}:${args.server.id}:${this.clientGeneration(client)}:${stableStringify(inputTree)}`, (signal) => runSolvePipeline({
817
+ definitionSource,
818
+ inputTree,
819
+ client,
820
+ responseMaxBytes: this.limits.computeResponseMaxBytes,
821
+ solveDeadlineMs: this.limits.solveDeadlineMs,
822
+ selvaEvents: args.selvaEvents,
823
+ signal
824
+ }), args.signal);
825
+ if (args.signal.aborted) return {
826
+ kind: "client_abort",
827
+ durationMs: elapsed()
828
+ };
829
+ if (flight.kind !== "solved") return flight;
830
+ let encoded;
831
+ try {
832
+ encoded = await flight.encoder.encode(args.acceptEncoding ?? "");
833
+ } catch (error) {
834
+ return {
835
+ kind: "compute_error",
836
+ durationMs: elapsed(),
837
+ error
838
+ };
839
+ }
840
+ if (encoded === "too_large") return { kind: "too_large" };
841
+ const { body, serializedBytes, serializeMs, gzipMs } = encoded;
842
+ const { solveMs, response } = flight;
843
+ const serverTotalMs = performance.now() - (args.loadStartMs ?? startedAt);
844
+ const timing = {
845
+ defLoadMs: args.defLoadMs ?? 0,
846
+ treeBuildMs,
847
+ solveMs,
848
+ serializeMs,
849
+ gzipMs,
850
+ serverTotalMs,
851
+ rhino: flight.rhino,
852
+ settle: flight.settle,
853
+ ...flight.defBytes ? { defBytes: flight.defBytes } : {}
854
+ };
855
+ const serverTiming = buildServerTiming({
856
+ ...timing,
857
+ prepMarks: args.prepMarks ?? []
858
+ });
859
+ return {
860
+ kind: "ok",
861
+ envelope: buildEnvelope(encoded, response, serverTiming, {
862
+ ...timing,
863
+ serializedBytes,
864
+ compressedBytes: encoded.encoding === "gzip" ? body.byteLength : null
865
+ }),
866
+ solveMs,
867
+ errorCount: response.errors?.length ?? 0,
868
+ warningCount: response.warnings?.length ?? 0
698
869
  };
870
+ }
871
+ /**
872
+ * The response for an outcome; the mapping from outcome to status lives here. Throws
873
+ * `compute_error`'s error: what it means for the caller is the host's to decide.
874
+ */
875
+ toWebResponse(outcome) {
876
+ const fail = (status, body) => new Response(JSON.stringify(body), {
877
+ status,
878
+ headers: {
879
+ "Content-Type": "application/json",
880
+ ...body.retryAfter !== void 0 ? { "Retry-After": String(body.retryAfter) } : {}
881
+ }
882
+ });
699
883
  switch (outcome.kind) {
700
- case "ok": return {
884
+ case "ok": return new Response(outcome.envelope.body, {
701
885
  status: 200,
702
- headers: outcome.envelope.headers,
703
- body: outcome.envelope.body
704
- };
886
+ headers: outcome.envelope.headers
887
+ });
705
888
  case "timeout": return fail(504, { message: outcome.message });
706
889
  case "client_abort": return fail(499, { message: "Client closed request" });
707
890
  case "too_large": return fail(413, { message: "Solve result is too large to return. This usually means a file output exceeds the supported size." });
@@ -712,29 +895,21 @@ var SolveEngine = class {
712
895
  case "compute_error": throw outcome.error;
713
896
  }
714
897
  }
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
- });
898
+ /**
899
+ * Part of the coalesce key: after an eviction the id stays and the client changes, and a new
900
+ * caller joining a draining flight would get an answer from the old URL or key.
901
+ */
902
+ clientGeneration(client) {
903
+ let generation = this.clientGenerations.get(client);
904
+ if (generation === void 0) {
905
+ generation = ++this.lastClientGeneration;
906
+ this.clientGenerations.set(client, generation);
907
+ }
908
+ return generation;
721
909
  }
722
910
  resolveDefinitionSource(args) {
723
911
  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 {
912
+ if (isByteCacheRef(source) || isDefinitionRef(source)) return {
738
913
  definitionSource: source,
739
914
  identity: source.key
740
915
  };
@@ -745,6 +920,6 @@ var SolveEngine = class {
745
920
  };
746
921
  }
747
922
  };
748
- export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, SolveEngine, adaptEnvelopeToEncoding, buildSolveInputTree, createClientCache, createDefinitionByteCache, createSolveCacheSingleFlight, isDefinitionRef, runSolvePipeline, serverIdentity };
923
+ export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, SolveEngine, acceptsGzip, solveInputsFromSchema };
749
924
 
750
925
  //# sourceMappingURL=server.js.map