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