@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/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