@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/README.md +16 -10
- package/dist/abort-CNJ8Sn4g.js +13 -0
- package/dist/abort-CNJ8Sn4g.js.map +1 -0
- package/dist/client.d.ts +33 -134
- package/dist/client.js +335 -240
- package/dist/client.js.map +1 -1
- package/dist/{outcome-DsNmx27P.d.ts → outcome-Bk9mnMi0.d.ts} +9 -28
- package/dist/{outcome-ai_9fm43.js → outcome-DNrffAXp.js} +48 -46
- package/dist/outcome-DNrffAXp.js.map +1 -0
- package/dist/server.d.ts +73 -222
- package/dist/server.js +594 -419
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +2 -2
- package/dist/shared.js +1 -1
- package/package.json +5 -13
- package/dist/outcome-ai_9fm43.js.map +0 -1
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
|
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* solve
|
|
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
|
|
14
|
+
const FALLBACK_CONCURRENCY = 1;
|
|
27
15
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
21
|
+
const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
|
|
32
22
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
366
|
-
if (
|
|
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
|
-
|
|
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
|
|
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)
|
|
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(() =>
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 ${
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
387
|
+
return wait(running, signal);
|
|
520
388
|
}
|
|
521
389
|
stats.misses += 1;
|
|
522
|
-
|
|
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,
|
|
566
|
+
run(key, work, callerSignal) {
|
|
550
567
|
const existing = inflight.get(key);
|
|
551
568
|
if (existing) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
|
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
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
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
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
653
|
-
const acceptEncoding = args.acceptEncoding ?? "";
|
|
654
|
-
let outcome;
|
|
785
|
+
let client;
|
|
655
786
|
try {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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 (
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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 (
|
|
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,
|
|
923
|
+
export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, SolveEngine, acceptsGzip, solveInputsFromSchema };
|
|
749
924
|
|
|
750
925
|
//# sourceMappingURL=server.js.map
|