@selvajs/solve 2.0.0-beta.9 → 2.0.0
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 +968 -1
- package/dist/client.js.map +1 -1
- package/dist/{outcome-DsNmx27P.d.ts → outcome-Bk9mnMi0.d.ts} +9 -28
- package/dist/outcome-DNrffAXp.js +169 -0
- package/dist/outcome-DNrffAXp.js.map +1 -0
- package/dist/server.d.ts +98 -227
- package/dist/server.js +984 -1
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +2 -2
- package/dist/shared.js +1 -1
- package/package.json +16 -41
- package/dist/client.cjs +0 -2
- package/dist/client.cjs.map +0 -1
- package/dist/client.d.cts +0 -429
- package/dist/outcome-BOrAeRAQ.js +0 -2
- package/dist/outcome-BOrAeRAQ.js.map +0 -1
- package/dist/outcome-DUHQoZqq.cjs +0 -2
- package/dist/outcome-DUHQoZqq.cjs.map +0 -1
- package/dist/outcome-DsNmx27P.d.cts +0 -92
- package/dist/server.cjs +0 -2
- package/dist/server.cjs.map +0 -1
- package/dist/server.d.cts +0 -416
- package/dist/shared.cjs +0 -1
- package/dist/shared.d.cts +0 -3
- package/dist/solve-input-CUjVGYPC.d.cts +0 -11
package/dist/server.js
CHANGED
|
@@ -1,2 +1,985 @@
|
|
|
1
|
-
import{GrasshopperClient as e,TreeBuilder as t,stableStringify as n}from"@selvajs/compute/grasshopper";import{ComputeError as r,ErrorCodes as i,enableDebugLogging as a,isDefinitionRef as o,isDefinitionRef as s}from"@selvajs/compute/core";import{gunzipSync as c,gzip as l}from"node:zlib";import{promisify as u}from"node:util";import{NoopLogger as d}from"@selvajs/platform";function f(e,n){return t.fromValues(e.filter(e=>e.paramType).map(e=>({paramName:e.nickname,paramType:e.paramType,value:n[e.id]??e.default,minimum:e.minimum,maximum:e.maximum})))}const p=u(l),m=1,h=`X-Selva-Compute-Version`;async function g(e){let{client:t,signal:n}=e,a=performance.now(),o=e.inputTree??f(e.inputs,e.values),s=performance.now()-a,c=null,l,u,d=performance.now();try{let r=await t.scheduler.solveDetailed(e.definitionSource,o,{signal:n,selvaevents:e.selvaEvents,onServerTiming:e=>{c=e}});l=r.response,u={fromCache:r.fromCache,definitionReuploaded:r.definitionReuploaded}}catch(t){let a=performance.now()-d,o=t instanceof Error&&t.name===`AbortError`||t instanceof r&&t.code===i.ABORTED,s=t instanceof r&&t.code===i.TIMEOUT_ERROR;if(o||s)return o&&n.aborted?{kind:`client_abort`,durationMs:a}:{kind:`timeout`,durationMs:a,message:`Solve exceeded the ${Math.round(e.solveDeadlineMs/1e3)}s deadline.`};if(t instanceof r){if(t.code===i.RESPONSE_TOO_LARGE)return{kind:`too_large`};if(t.code===i.QUEUE_FULL)return{kind:`shed`,durationMs:a,reason:`queue_full`,retryAfterSeconds:1,message:`Compute server is at capacity. Retry shortly.`};if(t.code===i.QUEUE_TIMEOUT)return{kind:`shed`,durationMs:a,reason:`queue_timeout`,retryAfterSeconds:1,message:`Compute request waited too long in the queue. Retry shortly.`}}return{kind:`compute_error`,durationMs:a,error:t}}let m=performance.now()-d,g=performance.now(),_;try{_=JSON.stringify(l)}catch(e){if(e instanceof RangeError)return{kind:`too_large`};throw e}if(_.length>e.responseMaxBytes)return{kind:`too_large`};let y=performance.now()-g,b=/\bgzip\b/i.test(e.acceptEncoding)&&_.length>1024,x=null,S=0;if(b){let e=performance.now();x=await p(Buffer.from(_)),S=performance.now()-e}let C=performance.now()-e.loadStartMs,w={"Content-Type":`application/json`,"Server-Timing":v({defLoadMs:e.defLoadMs,treeBuildMs:s,solveMs:m,serializeMs:y,gzipMs:S,serverTotalMs:C,rhino:c,settle:u,byteRefOutcome:e.byteRefOutcome,prepMarks:e.prepMarks??[]}),[h]:`1`,Vary:`Accept-Encoding`},T={treeBuildMs:s,solveMs:m,serializeMs:y,gzipMs:S,serverTotalMs:C,serializedBytes:_.length,compressedBytes:x?.byteLength??null},E;return x?(w[`Content-Encoding`]=`gzip`,w[`Content-Length`]=String(x.byteLength),E={body:new Uint8Array(x),encoding:`gzip`,headers:w,result:l,metrics:T}):(w[`Content-Length`]=String(Buffer.byteLength(_)),E={body:_,headers:w,result:l,metrics:T}),{kind:`ok`,envelope:E,solveMs:m,errorCount:l.errors?.length??0,warningCount:l.warnings?.length??0}}function _(e,t){let n=/\bgzip\b/i.test(t);if(e.encoding===`gzip`&&!n){let t=c(e.body instanceof Uint8Array?e.body:Buffer.from(e.body)).toString(`utf8`),n={...e.headers};return delete n[`Content-Encoding`],n[`Content-Length`]=String(Buffer.byteLength(t)),{body:t,headers:n}}return{body:e.body,headers:e.headers}}function v(e){let t=`load;dur=${e.defLoadMs.toFixed(1)}, tree;dur=${e.treeBuildMs.toFixed(1)}, solve;dur=${e.solveMs.toFixed(1)}, serialize;dur=${e.serializeMs.toFixed(1)}, gzip;dur=${e.gzipMs.toFixed(1)}, total;dur=${e.serverTotalMs.toFixed(1)}`,n=e.rhino;if(n){let r=n.decode??0,i=n.solve??0,a=n.encode??0,o=Math.max(0,e.solveMs-r-i-a);t+=`, rhino_decode;dur=${r.toFixed(1)}, rhino_solve;dur=${i.toFixed(1)}, rhino_encode;dur=${a.toFixed(1)}, compute_link;dur=${o.toFixed(1)}, rhino_cache;dur=${+!!n.cacheHit}`,n.gh!==void 0&&(t+=`, rhino_gh;dur=${n.gh.toFixed(1)}`),n.recomputed&&(t+=`, rhino_recomputed;desc="${n.recomputed.ran}/${n.recomputed.total}"`)}for(let[n,r]of e.prepMarks)t+=`, p_${n};dur=${r.toFixed(1)}`;let r=e.settle;t+=`, selva_cache;dur=${+!!r.fromCache}`,r.definitionReuploaded!==void 0&&(t+=`, def_reupload;dur=${+!!r.definitionReuploaded}`);let i=e.byteRefOutcome;if(i){let e=i.loaded?i.fromCache?`hit`:`miss`:`skipped`;t+=`, def_bytes;desc=${e}`}return t}function y(e){return e.id}const b=5e3;function x(t){let n=t.maxWarmComputeServers??16,r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,o=/* @__PURE__ */ new Map;t.debug===`verbose`&&a();let s=e=>{t.debug&&t.onDebugLog?.(e)};async function c(n,r){let i=t.debug===`verbose`,a={serverUrl:n.serverUrl,apiKey:n.apiKey,debug:i,cachesolve:t.cachesolve,maxResponseBytes:t.maxResponseBytes};t.debug&&(a.onServerTiming=e=>{if(e.cacheHit){s(`[Compute/rhino-cache] solve-cache (cachesolve): HIT (cached result, solve skipped)`);return}let n=e.decode??0,r=e.solve??0,i=e.encode??0,a=n<=1?`HIT (definition reused, no re-upload/parse)`:`miss (full parse)`,o=t.cachesolve?`miss (solved fresh)`:`off`,c=e.recomputed?` | ran ${e.recomputed.ran}/${e.recomputed.total} objects`:``;s(`[Compute/rhino-cache] decode=${n}ms solve=${r}ms encode=${i}ms | definition-cache: ${a} | solve-cache (cachesolve): ${o}${c}`)}),r&&(a.headers={"X-Selva-Definition":r});let o=await e.create(a),c=1,l=0,u=!1;async function d(e){if(!u){u=!0;try{let t=await Promise.resolve(o.serverStats?.getActiveChildren({initialize:!1})).catch(()=>null);l=Date.now();let n=!t||t<=0?1:t;if(n===c)return;s(`[Compute/client-cache] concurrency ${c} → ${n} (server child count)`),c=n,e?.setMaxConcurrent(n)}finally{u=!1}}}await d();let f=o.createScheduler({mode:`queue`,maxConcurrent:c,maxQueueDepth:t.maxQueueDepth||void 0,queueWaitMs:t.queueWaitMs||void 0,timeoutMs:t.solveDeadlineMs,cache:t.responseCacheMaxBytes>0&&{maxBytes:t.responseCacheMaxBytes},reuseServerDefinitionCache:t.reuseServerDefinitionCache,onSettle:(e,n)=>{if(n.status===`success`&&(n.fromCache!==!0&&Date.now()-l>=3e5&&d(f),t.debug&&(s(`[Compute/selva-cache] ${n.fromCache?`HIT — served from cache (no compute call)`:`miss — went to Rhino.Compute`} (${Math.round(n.durationMs)}ms)`),n.fromCache||s(n.definitionReuploaded===!0?`[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)`:n.definitionReuploaded===!1?`[Compute/def-cache] HIT — definition reused from server cache (no upload)`:`[Compute/def-cache] n/a — reuse disabled or non-reusable definition`),!n.fromCache))){let e=n.response?.errors,t=Array.isArray(e)?e.length:0;t>0&&s(`[Compute/rhino-cache] ${t} GH error(s); not cached by Rhino.Compute. First: ${JSON.stringify(e[0])}`)}}});return{client:o,scheduler:f}}return{async getClient(e,t){let a=e.id,l=r.get(a);if(l)return r.delete(a),r.set(a,l),l;let u=i.get(a);if(u)return u;let d=o.get(a);if(d){if(Date.now()-d.at<b)throw s(`[Compute/client-cache] server ${a} failed to build <${b}ms ago — replaying error`),d.error;o.delete(a)}s(`[Compute/client-cache] miss — building warm client for server ${a}`);let f=(async()=>{let i;try{i=await c(e,t?.definitionGuid)}catch(e){throw o.set(a,{error:e,at:Date.now()}),e}if(r.size>=n){let e=r.keys().next().value;e!==void 0&&(s(`[Compute/client-cache] LRU evicted warm client for server ${e} (cap ${n})`),r.get(e)?.scheduler.dispose(),r.delete(e))}return r.set(a,i),i})().finally(()=>i.delete(a));return i.set(a,f),f},evict(e){let t=e;o.delete(t);let n=r.get(t);n&&(s(`[Compute/client-cache] evicted warm client for server ${t} (config change)`),n.scheduler.dispose(),r.delete(t))},solveCacheStats(){let e={warmClients:r.size,entries:0,bytes:0,hits:0,misses:0,evictions:0};for(let t of r.values()){let n=t.scheduler.cacheStats();e.entries+=n.entries,e.bytes+=n.bytes,e.hits+=n.hits,e.misses+=n.misses,e.evictions+=n.evictions}return e},clearSolveCaches(){s(`[Compute/client-cache] cleared solve caches on ${r.size} warm client(s)`);for(let e of r.values())e.scheduler.clearCache()},disposeAll(){s(`[Compute/client-cache] disposing all ${r.size} warm client(s)`);for(let e of r.values())e.scheduler.dispose();r.clear(),o.clear()}}}function S(e){let t=Math.max(0,Math.floor(e)),n=/* @__PURE__ */ new Map,r=0,i={hits:0,misses:0,evictions:0};function a(e,t){n.delete(e),n.set(e,t)}function o(){for(;r>t&&n.size>0;){let e=n.keys().next().value,t=n.get(e);n.delete(e),r-=t.bytes.byteLength,i.evictions+=1}}function s(e,i){if(t===0||i.byteLength>t)return;let a=n.get(e);a&&(r-=a.bytes.byteLength),n.set(e,{bytes:i}),r+=i.byteLength,o()}return{getOrLoad(e,t){let r={loaded:!1,fromCache:!1};return{key:e,outcome:r,load:async()=>{let o=n.get(e);if(o)return a(e,o),i.hits+=1,r.loaded=!0,r.fromCache=!0,o.bytes;i.misses+=1,r.loaded=!0,r.fromCache=!1;let c=await t();return s(e,c),c}}},stats(){return{hits:i.hits,misses:i.misses,evictions:i.evictions,entries:n.size,bytes:r}},clear(){n.clear(),r=0}}}function C(e={}){let t=/* @__PURE__ */ new Map;return{run(n,r,i){let a=t.get(n);if(a)return e.onJoin?.(n),a.notifyOwner?.(),a.promise;let o={notifyOwner:i,promise:void 0},s=(async()=>r())().finally(()=>{o.notifyOwner=void 0,t.get(n)===o&&t.delete(n)});return o.promise=s,t.set(n,o),s},inFlight(){return t.size}}}function w(e){return typeof e==`object`&&!!e&&!(e instanceof Uint8Array)&&`outcome`in e}function T(e){return typeof e==`object`&&!!e&&!(e instanceof Uint8Array)&&!(`outcome`in e)&&!(`key`in e)&&`versionId`in e}var E=class{limits;clientCache;byteCache;singleFlight;constructor(e){this.limits=e.limits;let t=e.logger??new d;this.clientCache=x({solveDeadlineMs:this.limits.solveDeadlineMs,cachesolve:this.limits.computeServerCachesolve,reuseServerDefinitionCache:this.limits.computeReuseDefinitionCache,maxQueueDepth:this.limits.computeMaxQueueDepth,queueWaitMs:this.limits.computeQueueWaitMs,responseCacheMaxBytes:this.limits.computeSolveCacheBytes,maxResponseBytes:this.limits.computeResponseMaxBytes,debug:e.debug??!1,maxWarmComputeServers:e.maxWarmComputeServers,onDebugLog:e.onDebugLog??(e=>t.debug(e,{component:`Compute/client-cache`}))}),this.byteCache=S(this.limits.computeDefinitionCacheBytes),this.singleFlight=C({onJoin:e.onSolveCoalesced})}getClient(e,t){return this.clientCache.getClient(e,t)}definitionRef(e,t){return this.byteCache.getOrLoad(e,t)}evictServer(e){this.clientCache.evict(e)}clearSolveCaches(){this.clientCache.clearSolveCaches()}clearDefinitionCache(){this.byteCache.clear()}stats(){return{client:this.clientCache.solveCacheStats(),definitionBytes:this.byteCache.stats(),coalescing:{inFlight:this.singleFlight.inFlight()}}}async solve(e){let{definitionSource:t,byteRefOutcome:r,identity:i}=this.resolveDefinitionSource(e),a=await this.clientCache.getClient(e.server,{definitionGuid:e.definitionGuid}),o=f(e.inputs,e.values),s=`${i}:${e.server.id}:${n(o)}`,c=new AbortController,l=!1,u=()=>{l||c.abort()};e.signal.addEventListener(`abort`,u,{once:!0});let d=e.acceptEncoding??``,p;try{p=await this.singleFlight.run(s,()=>g({definitionSource:t,byteRefOutcome:r,inputs:e.inputs,values:e.values,inputTree:o,client:a,responseMaxBytes:this.limits.computeResponseMaxBytes,solveDeadlineMs:this.limits.solveDeadlineMs,acceptEncoding:d,selvaEvents:e.selvaEvents,signal:c.signal,loadStartMs:e.loadStartMs??performance.now(),defLoadMs:e.defLoadMs??0,prepMarks:e.prepMarks}),()=>{l=!0})}finally{e.signal.removeEventListener(`abort`,u)}if(p.kind!==`ok`)return p;let m=_(p.envelope,d);return{...p,envelope:{...p.envelope,body:m.body,headers:m.headers}}}toResponse(e,t){let n=(e,n)=>{t?.onError&&t.onError(e,n);let r={"Content-Type":`application/json`};return n.retryAfter!==void 0&&(r[`Retry-After`]=String(n.retryAfter)),{status:e,headers:r,body:JSON.stringify(n)}};switch(e.kind){case`ok`:return{status:200,headers:e.envelope.headers,body:e.envelope.body};case`timeout`:return n(504,{message:e.message});case`client_abort`:return n(499,{message:`Client closed request`});case`too_large`:return n(413,{message:`Solve result is too large to return. This usually means a file output exceeds the supported size.`});case`shed`:return n(503,{message:e.message,retryAfter:e.retryAfterSeconds});case`compute_error`:throw e.error}}toWebResponse(e){let t=this.toResponse(e);return new Response(typeof t.body==`string`?t.body:new Uint8Array(t.body),{status:t.status,headers:t.headers})}resolveDefinitionSource(e){let t=e.definitionSource;if(T(t)){let e=this.byteCache.getOrLoad(t.versionId,t.load);return{definitionSource:e,byteRefOutcome:e.outcome,identity:e.key}}if(w(t))return{definitionSource:t,byteRefOutcome:t.outcome,identity:t.key};if(s(t))return{definitionSource:t,identity:t.key};if(!e.definitionKey)throw Error(`SolveEngine.solve(): definitionKey is required when definitionSource is raw bytes or a string.`);return{definitionSource:t,identity:e.definitionKey}}};export{m as COMPUTE_CONTRACT_VERSION,h as COMPUTE_VERSION_HEADER,E as SolveEngine,_ as adaptEnvelopeToEncoding,f as buildSolveInputTree,x as createClientCache,S as createDefinitionByteCache,C as createSolveCacheSingleFlight,o as isDefinitionRef,g as runSolvePipeline,y as serverIdentity};
|
|
1
|
+
import { t as raceAbort } from "./abort-CNJ8Sn4g.js";
|
|
2
|
+
import { getInputItems } from "@selvajs/schemas";
|
|
3
|
+
import { GrasshopperClient, TreeBuilder, stableStringify } from "@selvajs/compute/grasshopper";
|
|
4
|
+
import { ComputeError, ErrorCodes, enableDebugLogging, isDefinitionRef } from "@selvajs/compute/core";
|
|
5
|
+
import { Agent, fetch } from "undici";
|
|
6
|
+
import { gzip } from "node:zlib";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
// src/server/concurrency-probe.ts
|
|
9
|
+
/**
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
const FALLBACK_CONCURRENCY = 1;
|
|
15
|
+
/**
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
|
|
22
|
+
/**
|
|
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.
|
|
31
|
+
*/
|
|
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;
|
|
40
|
+
try {
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
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
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// src/server/long-fetch.ts
|
|
63
|
+
/** undici's default `headersTimeout` and `bodyTimeout`, which `timeoutMs` can't extend. */
|
|
64
|
+
const UNDICI_DEFAULT_LIMIT_MS = 3e5;
|
|
65
|
+
/** Both off, not raised: the scheduler's `timeoutMs` stays the one deadline on a solve. */
|
|
66
|
+
const unlimited = new Agent({
|
|
67
|
+
headersTimeout: 0,
|
|
68
|
+
bodyTimeout: 0
|
|
69
|
+
});
|
|
70
|
+
const unlimitedFetch = ((input, init) => fetch(input, {
|
|
71
|
+
...init,
|
|
72
|
+
signal: init?.signal ?? AbortSignal.timeout(UNDICI_DEFAULT_LIMIT_MS),
|
|
73
|
+
dispatcher: unlimited
|
|
74
|
+
}));
|
|
75
|
+
/** The fetch for a solve deadline: `undefined` means the global one reaches it. */
|
|
76
|
+
function fetchForDeadline(deadlineMs) {
|
|
77
|
+
return deadlineMs >= UNDICI_DEFAULT_LIMIT_MS ? unlimitedFetch : void 0;
|
|
78
|
+
}
|
|
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;
|
|
82
|
+
/**
|
|
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.
|
|
85
|
+
*/
|
|
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;
|
|
106
|
+
/**
|
|
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.
|
|
109
|
+
*/
|
|
110
|
+
const LIMITS_RECHECK_MS = 3e5;
|
|
111
|
+
/**
|
|
112
|
+
* How long a failed build is remembered before the next `getClient` retries the
|
|
113
|
+
* handshake. Without this, every solve against a down server pays the full probe
|
|
114
|
+
* ladder again, so a user clicking Solve twice waits twice.
|
|
115
|
+
*
|
|
116
|
+
* Short on purpose: this is a convenience for the rapid-retry case, not a
|
|
117
|
+
* circuit breaker. An operator who starts the compute VM should not have to wait
|
|
118
|
+
* out a long penalty window before the app notices.
|
|
119
|
+
*/
|
|
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
|
+
}
|
|
129
|
+
function createClientCache(config) {
|
|
130
|
+
const maxWarmComputeServers = config.maxWarmComputeServers ?? DEFAULT_MAX_WARM_COMPUTE_SERVERS;
|
|
131
|
+
const cache = /* @__PURE__ */ new Map();
|
|
132
|
+
const pending = /* @__PURE__ */ new Map();
|
|
133
|
+
const failed = /* @__PURE__ */ new Map();
|
|
134
|
+
const draining = /* @__PURE__ */ new Set();
|
|
135
|
+
if (config.debug === "verbose") enableDebugLogging();
|
|
136
|
+
const debugLog = (message) => {
|
|
137
|
+
if (config.debug) config.onDebugLog?.(message);
|
|
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
|
+
}
|
|
152
|
+
async function build(server, definitionGuid) {
|
|
153
|
+
const debugVerbose = config.debug === "verbose";
|
|
154
|
+
const longFetch = fetchForDeadline(MAX_SOLVE_DEADLINE_MS);
|
|
155
|
+
const clientConfig = {
|
|
156
|
+
serverUrl: server.serverUrl,
|
|
157
|
+
apiKey: server.apiKey,
|
|
158
|
+
debug: debugVerbose,
|
|
159
|
+
cachesolve: config.cachesolve,
|
|
160
|
+
maxResponseBytes: config.maxResponseBytes,
|
|
161
|
+
...longFetch && { fetch: longFetch }
|
|
162
|
+
};
|
|
163
|
+
if (config.debug) clientConfig.onServerTiming = (t) => {
|
|
164
|
+
if (t.cacheHit) {
|
|
165
|
+
debugLog("[Compute/rhino-cache] solve-cache (cachesolve): HIT (cached result, solve skipped)");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const decode = t.decode ?? 0;
|
|
169
|
+
const solve = t.solve ?? 0;
|
|
170
|
+
const encode = t.encode ?? 0;
|
|
171
|
+
const defCache = decode <= 1 ? "HIT (definition reused, no re-upload/parse)" : "miss (full parse)";
|
|
172
|
+
const solveCache = config.cachesolve ? "miss (solved fresh)" : "off";
|
|
173
|
+
const ran = t.recomputed ? ` | ran ${t.recomputed.ran}/${t.recomputed.total} objects` : "";
|
|
174
|
+
debugLog(`[Compute/rhino-cache] decode=${decode}ms solve=${solve}ms encode=${encode}ms | definition-cache: ${defCache} | solve-cache (cachesolve): ${solveCache}${ran}`);
|
|
175
|
+
};
|
|
176
|
+
if (definitionGuid) clientConfig.headers = { "X-Selva-Definition": definitionGuid };
|
|
177
|
+
const client = await GrasshopperClient.create(clientConfig);
|
|
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
|
+
};
|
|
196
|
+
const scheduler = client.createScheduler({
|
|
197
|
+
mode: "queue",
|
|
198
|
+
maxConcurrent: probe.maxConcurrent,
|
|
199
|
+
maxQueueDepth: config.maxQueueDepth || void 0,
|
|
200
|
+
queueWaitMs: config.queueWaitMs || void 0,
|
|
201
|
+
timeoutMs: limits.solveDeadlineMs,
|
|
202
|
+
cache: config.responseCacheMaxBytes > 0 ? { maxBytes: config.responseCacheMaxBytes } : false,
|
|
203
|
+
reuseServerDefinitionCache: limits.reuseDefinitionCache,
|
|
204
|
+
onSettle: (_ctx, result) => {
|
|
205
|
+
queueMicrotask(() => finishDraining(entry));
|
|
206
|
+
if (result.status !== "success") return;
|
|
207
|
+
if (result.fromCache !== true) {
|
|
208
|
+
probe.refreshIfDue(scheduler);
|
|
209
|
+
recheckLimitsIfDue();
|
|
210
|
+
}
|
|
211
|
+
if (config.debug) describeSettledSolve(result).forEach(debugLog);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const entry = {
|
|
215
|
+
client,
|
|
216
|
+
scheduler,
|
|
217
|
+
limits
|
|
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);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
async getClient(server, opts) {
|
|
229
|
+
const key = server.id;
|
|
230
|
+
const existing = cache.get(key);
|
|
231
|
+
if (existing) {
|
|
232
|
+
cache.delete(key);
|
|
233
|
+
cache.set(key, existing);
|
|
234
|
+
return existing;
|
|
235
|
+
}
|
|
236
|
+
const inFlight = pending.get(key);
|
|
237
|
+
if (inFlight) return inFlight.promise;
|
|
238
|
+
const recentFailure = failed.get(key);
|
|
239
|
+
if (recentFailure) {
|
|
240
|
+
if (Date.now() - recentFailure.at < FAILED_BUILD_TTL_MS) {
|
|
241
|
+
debugLog(`[Compute/client-cache] server ${key} failed to build <${FAILED_BUILD_TTL_MS}ms ago — replaying error`);
|
|
242
|
+
throw recentFailure.error;
|
|
243
|
+
}
|
|
244
|
+
failed.delete(key);
|
|
245
|
+
}
|
|
246
|
+
debugLog(`[Compute/client-cache] miss — building warm client for server ${key}`);
|
|
247
|
+
const token = { stale: false };
|
|
248
|
+
const promise = (async () => {
|
|
249
|
+
let entry;
|
|
250
|
+
try {
|
|
251
|
+
entry = await build(server, opts?.definitionGuid);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (!token.stale) failed.set(key, {
|
|
254
|
+
error: err,
|
|
255
|
+
at: Date.now()
|
|
256
|
+
});
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
if (token.stale) {
|
|
260
|
+
entry.scheduler.dispose();
|
|
261
|
+
throw new ComputeError(`Compute server ${key} changed while connecting.`, ErrorCodes.ABORTED);
|
|
262
|
+
}
|
|
263
|
+
if (cache.size >= maxWarmComputeServers) {
|
|
264
|
+
const oldestKey = cache.keys().next().value;
|
|
265
|
+
if (oldestKey !== void 0) {
|
|
266
|
+
debugLog(`[Compute/client-cache] LRU evicted warm client for server ${oldestKey} (cap ${maxWarmComputeServers})`);
|
|
267
|
+
const oldest = cache.get(oldestKey);
|
|
268
|
+
cache.delete(oldestKey);
|
|
269
|
+
if (oldest) retire(oldest);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
cache.set(key, entry);
|
|
273
|
+
return entry;
|
|
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;
|
|
282
|
+
},
|
|
283
|
+
evict(id) {
|
|
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);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
solveCacheStats() {
|
|
296
|
+
const total = {
|
|
297
|
+
warmClients: cache.size,
|
|
298
|
+
entries: 0,
|
|
299
|
+
bytes: 0,
|
|
300
|
+
hits: 0,
|
|
301
|
+
misses: 0,
|
|
302
|
+
evictions: 0
|
|
303
|
+
};
|
|
304
|
+
for (const entry of cache.values()) {
|
|
305
|
+
const s = entry.scheduler.cacheStats();
|
|
306
|
+
total.entries += s.entries;
|
|
307
|
+
total.bytes += s.bytes;
|
|
308
|
+
total.hits += s.hits;
|
|
309
|
+
total.misses += s.misses;
|
|
310
|
+
total.evictions += s.evictions;
|
|
311
|
+
}
|
|
312
|
+
return total;
|
|
313
|
+
},
|
|
314
|
+
clearSolveCaches() {
|
|
315
|
+
debugLog(`[Compute/client-cache] cleared solve caches on ${cache.size} warm client(s)`);
|
|
316
|
+
for (const entry of cache.values()) entry.scheduler.clearCache();
|
|
317
|
+
},
|
|
318
|
+
disposeAll() {
|
|
319
|
+
debugLog(`[Compute/client-cache] disposing all ${cache.size} warm client(s)`);
|
|
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();
|
|
323
|
+
cache.clear();
|
|
324
|
+
draining.clear();
|
|
325
|
+
failed.clear();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
// src/server/definition-byte-cache.ts
|
|
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
|
+
/**
|
|
355
|
+
* @param maxBytes total retained-byte budget; `0` (or negative) disables caching
|
|
356
|
+
* entirely — every `load()` calls the loader and nothing is retained.
|
|
357
|
+
*/
|
|
358
|
+
function createDefinitionByteCache(maxBytes) {
|
|
359
|
+
const budget = Math.max(0, Math.floor(maxBytes));
|
|
360
|
+
const cache = /* @__PURE__ */ new Map();
|
|
361
|
+
const loading = /* @__PURE__ */ new Map();
|
|
362
|
+
let retainedBytes = 0;
|
|
363
|
+
const stats = {
|
|
364
|
+
hits: 0,
|
|
365
|
+
misses: 0,
|
|
366
|
+
evictions: 0
|
|
367
|
+
};
|
|
368
|
+
function evictToFit() {
|
|
369
|
+
for (const [key, bytes] of cache) {
|
|
370
|
+
if (retainedBytes <= budget) break;
|
|
371
|
+
cache.delete(key);
|
|
372
|
+
retainedBytes -= bytes.byteLength;
|
|
373
|
+
stats.evictions += 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function store(key, bytes) {
|
|
377
|
+
if (budget === 0) return;
|
|
378
|
+
if (bytes.byteLength > budget) return;
|
|
379
|
+
cache.set(key, bytes);
|
|
380
|
+
retainedBytes += bytes.byteLength;
|
|
381
|
+
evictToFit();
|
|
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
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
getOrLoad(versionId, load) {
|
|
415
|
+
const outcome = {
|
|
416
|
+
loaded: false,
|
|
417
|
+
fromCache: false
|
|
418
|
+
};
|
|
419
|
+
return {
|
|
420
|
+
key: versionId,
|
|
421
|
+
outcome,
|
|
422
|
+
load: async function read(signal) {
|
|
423
|
+
outcome.loaded = true;
|
|
424
|
+
const hit = cache.get(versionId);
|
|
425
|
+
if (hit) {
|
|
426
|
+
cache.delete(versionId);
|
|
427
|
+
cache.set(versionId, hit);
|
|
428
|
+
stats.hits += 1;
|
|
429
|
+
outcome.fromCache = true;
|
|
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);
|
|
442
|
+
}
|
|
443
|
+
stats.misses += 1;
|
|
444
|
+
return wait(startLoad(versionId, load), signal);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
stats() {
|
|
449
|
+
return {
|
|
450
|
+
hits: stats.hits,
|
|
451
|
+
misses: stats.misses,
|
|
452
|
+
evictions: stats.evictions,
|
|
453
|
+
entries: cache.size,
|
|
454
|
+
bytes: retainedBytes
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
clear() {
|
|
458
|
+
cache.clear();
|
|
459
|
+
retainedBytes = 0;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
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
|
+
}
|
|
602
|
+
// src/server/solve-cache-single-flight.ts
|
|
603
|
+
function createSolveCacheSingleFlight(options = {}) {
|
|
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
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
run(key, work, callerSignal) {
|
|
621
|
+
const existing = inflight.get(key);
|
|
622
|
+
if (existing) {
|
|
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);
|
|
629
|
+
}
|
|
630
|
+
const controller = new AbortController();
|
|
631
|
+
const flight = {
|
|
632
|
+
controller,
|
|
633
|
+
callers: 0,
|
|
634
|
+
promise: (async () => work(controller.signal))().finally(() => free(key, flight))
|
|
635
|
+
};
|
|
636
|
+
inflight.set(key, flight);
|
|
637
|
+
return attend(key, flight, callerSignal);
|
|
638
|
+
},
|
|
639
|
+
inFlight() {
|
|
640
|
+
return inflight.size;
|
|
641
|
+
}
|
|
642
|
+
};
|
|
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
|
+
}
|
|
757
|
+
// src/server/solve-engine.ts
|
|
758
|
+
/**
|
|
759
|
+
* Facade over the warm-client cache, definition-byte cache, single-flight
|
|
760
|
+
* coalescing, and the solve pipeline — the primitives an app route would
|
|
761
|
+
* otherwise wire up by hand.
|
|
762
|
+
*
|
|
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.
|
|
766
|
+
*
|
|
767
|
+
* Stays out of auth, DB reads, share tokens, rate limiting, metrics, and
|
|
768
|
+
* compute-server selection — all app policy, supplied per-call via
|
|
769
|
+
* `SolveEngineSolveArgs.server`.
|
|
770
|
+
*/
|
|
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);
|
|
782
|
+
}
|
|
783
|
+
var SolveEngine = class {
|
|
784
|
+
limits;
|
|
785
|
+
clientCache;
|
|
786
|
+
byteCache;
|
|
787
|
+
singleFlight;
|
|
788
|
+
clientGenerations = /* @__PURE__ */ new WeakMap();
|
|
789
|
+
lastClientGeneration = 0;
|
|
790
|
+
constructor(options) {
|
|
791
|
+
assertValidLimits(options.limits);
|
|
792
|
+
this.limits = options.limits;
|
|
793
|
+
this.clientCache = createClientCache({
|
|
794
|
+
cachesolve: this.limits.computeServerCachesolve,
|
|
795
|
+
maxQueueDepth: this.limits.computeMaxQueueDepth,
|
|
796
|
+
queueWaitMs: this.limits.computeQueueWaitMs,
|
|
797
|
+
responseCacheMaxBytes: this.limits.computeSolveCacheBytes,
|
|
798
|
+
maxResponseBytes: this.limits.computeResponseMaxBytes,
|
|
799
|
+
debug: options.debug ?? false,
|
|
800
|
+
maxWarmComputeServers: options.maxWarmComputeServers,
|
|
801
|
+
onDebugLog: options.onDebugLog
|
|
802
|
+
});
|
|
803
|
+
this.byteCache = createDefinitionByteCache(this.limits.computeDefinitionCacheBytes);
|
|
804
|
+
this.singleFlight = createSolveCacheSingleFlight({ onJoin: options.onSolveCoalesced });
|
|
805
|
+
}
|
|
806
|
+
getClient(server, opts) {
|
|
807
|
+
return this.clientCache.getClient(server, opts);
|
|
808
|
+
}
|
|
809
|
+
definitionRef(versionId, load) {
|
|
810
|
+
return this.byteCache.getOrLoad(versionId, load);
|
|
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
|
+
*/
|
|
816
|
+
evictServer(id) {
|
|
817
|
+
this.clientCache.evict(id);
|
|
818
|
+
}
|
|
819
|
+
clearSolveCaches() {
|
|
820
|
+
this.clientCache.clearSolveCaches();
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Drop every retained `.gh` byte entry. Safe at any time: entries key on an
|
|
824
|
+
* immutable version id, so clearing costs a storage re-read and nothing else.
|
|
825
|
+
*/
|
|
826
|
+
clearDefinitionCache() {
|
|
827
|
+
this.byteCache.clear();
|
|
828
|
+
}
|
|
829
|
+
stats() {
|
|
830
|
+
return {
|
|
831
|
+
client: this.clientCache.solveCacheStats(),
|
|
832
|
+
definitionBytes: this.byteCache.stats(),
|
|
833
|
+
coalescing: { inFlight: this.singleFlight.inFlight() }
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
async solve(args) {
|
|
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
|
|
843
|
+
};
|
|
844
|
+
let client;
|
|
845
|
+
try {
|
|
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
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (args.signal.aborted) return {
|
|
860
|
+
kind: "client_abort",
|
|
861
|
+
durationMs: elapsed()
|
|
862
|
+
};
|
|
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 live = args.selvaEvents ? `:live:${args.selvaEvents.solveId}` : "";
|
|
876
|
+
const flight = await this.singleFlight.run(`${identity}:${args.server.id}:${this.clientGeneration(client)}:${stableStringify(inputTree)}${live}`, (signal) => runSolvePipeline({
|
|
877
|
+
definitionSource,
|
|
878
|
+
inputTree,
|
|
879
|
+
client,
|
|
880
|
+
responseMaxBytes: this.limits.computeResponseMaxBytes,
|
|
881
|
+
selvaEvents: args.selvaEvents,
|
|
882
|
+
signal
|
|
883
|
+
}), args.signal);
|
|
884
|
+
if (args.signal.aborted) return {
|
|
885
|
+
kind: "client_abort",
|
|
886
|
+
durationMs: elapsed()
|
|
887
|
+
};
|
|
888
|
+
if (flight.kind !== "solved") return flight;
|
|
889
|
+
let encoded;
|
|
890
|
+
try {
|
|
891
|
+
encoded = await flight.encoder.encode(args.acceptEncoding ?? "");
|
|
892
|
+
} catch (error) {
|
|
893
|
+
return {
|
|
894
|
+
kind: "compute_error",
|
|
895
|
+
durationMs: elapsed(),
|
|
896
|
+
error
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
if (encoded === "too_large") return { kind: "too_large" };
|
|
900
|
+
const { body, serializedBytes, serializeMs, gzipMs } = encoded;
|
|
901
|
+
const { solveMs, response } = flight;
|
|
902
|
+
const serverTotalMs = performance.now() - (args.loadStartMs ?? startedAt);
|
|
903
|
+
const timing = {
|
|
904
|
+
defLoadMs: args.defLoadMs ?? 0,
|
|
905
|
+
treeBuildMs,
|
|
906
|
+
solveMs,
|
|
907
|
+
serializeMs,
|
|
908
|
+
gzipMs,
|
|
909
|
+
serverTotalMs,
|
|
910
|
+
rhino: flight.rhino,
|
|
911
|
+
settle: flight.settle,
|
|
912
|
+
...flight.defBytes ? { defBytes: flight.defBytes } : {}
|
|
913
|
+
};
|
|
914
|
+
const serverTiming = buildServerTiming({
|
|
915
|
+
...timing,
|
|
916
|
+
prepMarks: args.prepMarks ?? []
|
|
917
|
+
});
|
|
918
|
+
return {
|
|
919
|
+
kind: "ok",
|
|
920
|
+
envelope: buildEnvelope(encoded, response, serverTiming, {
|
|
921
|
+
...timing,
|
|
922
|
+
serializedBytes,
|
|
923
|
+
compressedBytes: encoded.encoding === "gzip" ? body.byteLength : null
|
|
924
|
+
}),
|
|
925
|
+
solveMs,
|
|
926
|
+
errorCount: response.errors?.length ?? 0,
|
|
927
|
+
warningCount: response.warnings?.length ?? 0
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* The response for an outcome; the mapping from outcome to status lives here. Throws
|
|
932
|
+
* `compute_error`'s error: what it means for the caller is the host's to decide.
|
|
933
|
+
*/
|
|
934
|
+
toWebResponse(outcome) {
|
|
935
|
+
const fail = (status, body) => new Response(JSON.stringify(body), {
|
|
936
|
+
status,
|
|
937
|
+
headers: {
|
|
938
|
+
"Content-Type": "application/json",
|
|
939
|
+
...body.retryAfter !== void 0 ? { "Retry-After": String(body.retryAfter) } : {}
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
switch (outcome.kind) {
|
|
943
|
+
case "ok": return new Response(outcome.envelope.body, {
|
|
944
|
+
status: 200,
|
|
945
|
+
headers: outcome.envelope.headers
|
|
946
|
+
});
|
|
947
|
+
case "timeout": return fail(504, { message: outcome.message });
|
|
948
|
+
case "client_abort": return fail(499, { message: "Client closed request" });
|
|
949
|
+
case "too_large": return fail(413, { message: "Solve result is too large to return. This usually means a file output exceeds the supported size." });
|
|
950
|
+
case "request_too_large": return fail(413, { message: outcome.message });
|
|
951
|
+
case "shed": return fail(503, {
|
|
952
|
+
message: outcome.message,
|
|
953
|
+
retryAfter: outcome.retryAfterSeconds
|
|
954
|
+
});
|
|
955
|
+
case "compute_error": throw outcome.error;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Part of the coalesce key: after an eviction the id stays and the client changes, and a new
|
|
960
|
+
* caller joining a draining flight would get an answer from the old URL or key.
|
|
961
|
+
*/
|
|
962
|
+
clientGeneration(client) {
|
|
963
|
+
let generation = this.clientGenerations.get(client);
|
|
964
|
+
if (generation === void 0) {
|
|
965
|
+
generation = ++this.lastClientGeneration;
|
|
966
|
+
this.clientGenerations.set(client, generation);
|
|
967
|
+
}
|
|
968
|
+
return generation;
|
|
969
|
+
}
|
|
970
|
+
resolveDefinitionSource(args) {
|
|
971
|
+
const source = args.definitionSource;
|
|
972
|
+
if (isByteCacheRef(source) || isDefinitionRef(source)) return {
|
|
973
|
+
definitionSource: source,
|
|
974
|
+
identity: source.key
|
|
975
|
+
};
|
|
976
|
+
if (!args.definitionKey) throw new Error("SolveEngine.solve(): definitionKey is required when definitionSource is raw bytes or a string.");
|
|
977
|
+
return {
|
|
978
|
+
definitionSource: source,
|
|
979
|
+
identity: args.definitionKey
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, DEFAULT_SOLVE_DEADLINE_MS, MAX_SOLVE_DEADLINE_MS, SolveEngine, acceptsGzip, solveInputsFromSchema };
|
|
984
|
+
|
|
2
985
|
//# sourceMappingURL=server.js.map
|