@selvajs/solve 1.3.0-beta.3 → 2.0.0-beta.11
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/client.d.ts +1 -21
- package/dist/client.js +873 -1
- package/dist/client.js.map +1 -1
- package/dist/{outcome-ePNabW7y.d.ts → outcome-DsNmx27P.d.ts} +1 -5
- package/dist/outcome-ai_9fm43.js +167 -0
- package/dist/{outcome-H5XjAroQ.js.map → outcome-ai_9fm43.js.map} +1 -1
- package/dist/server.d.ts +11 -63
- package/dist/server.js +749 -1
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +2 -2
- package/dist/shared.js +1 -1
- package/dist/solve-input-CUjVGYPC.d.ts +11 -0
- package/package.json +18 -35
- package/dist/client.cjs +0 -2
- package/dist/client.cjs.map +0 -1
- package/dist/client.d.cts +0 -449
- package/dist/outcome-DdbgqiIG.cjs +0 -2
- package/dist/outcome-DdbgqiIG.cjs.map +0 -1
- package/dist/outcome-H5XjAroQ.js +0 -2
- package/dist/outcome-ePNabW7y.d.cts +0 -96
- package/dist/server.cjs +0 -2
- package/dist/server.cjs.map +0 -1
- package/dist/server.d.cts +0 -468
- package/dist/shared.cjs +0 -1
- package/dist/shared.d.cts +0 -3
- package/dist/solve-input-BDv_JdvS.d.cts +0 -15
- package/dist/solve-input-BDv_JdvS.d.ts +0 -15
package/dist/server.js
CHANGED
|
@@ -1,2 +1,750 @@
|
|
|
1
|
-
import{GrasshopperClient as e,TreeBuilder as t,isDefinitionRef as n,processInput as r,stableStringify as i}from"@selvajs/compute/grasshopper";import{ComputeError as a,ErrorCodes as o,enableDebugLogging as s,isDefinitionRef as c}from"@selvajs/compute/core";import{gunzipSync as l,gzip as u}from"node:zlib";import{promisify as d}from"node:util";import{NoopLogger as f}from"@selvajs/platform";function p(e,t){let n={id:e.id,name:e.nickname,nickname:e.nickname||null,description:e.description||``,paramType:e.paramType===`dynamicValueList`?`valueList`:e.paramType,treeAccess:e.inputStructure===`tree`,minimum:e.minimum??null,maximum:e.maximum??null,atLeast:1,atMost:1,stepSize:e.stepSize,default:t??e.default},i=r(n),a=t??e.default;return i.default==null&&a!=null&&(Array.isArray(a)||i.paramType===`ValueList`&&typeof a!=`object`)&&(i.default=a),i}const m=d(u),h=1,g=`X-Selva-Compute-Version`;function _(e,n){return t.fromInputParams(e.filter(e=>e.paramType).map(e=>p(e,n[e.id])))}async function v(e){let{client:t,signal:n}=e,r=performance.now(),i=e.inputTree??_(e.inputs,e.values),s=performance.now()-r,c=t.solveMeta.seq,l=t.rhinoTiming.seq,u,d=performance.now();try{u=await t.scheduler.solve(e.definitionSource,i,{signal:n,selvaevents:e.selvaEvents})}catch(t){let r=performance.now()-d,i=t instanceof Error&&t.name===`AbortError`||t instanceof a&&t.code===o.ABORTED,s=t instanceof a&&t.code===o.TIMEOUT_ERROR;if(i||s)return i&&n.aborted?{kind:`client_abort`,durationMs:r}:{kind:`timeout`,durationMs:r,message:`Solve exceeded the ${Math.round(e.solveDeadlineMs/1e3)}s deadline.`};if(t instanceof a){if(t.code===o.QUEUE_FULL)return{kind:`shed`,durationMs:r,reason:`queue_full`,retryAfterSeconds:1,message:`Compute server is at capacity. Retry shortly.`};if(t.code===o.QUEUE_TIMEOUT)return{kind:`shed`,durationMs:r,reason:`queue_timeout`,retryAfterSeconds:1,message:`Compute request waited too long in the queue. Retry shortly.`}}return{kind:`compute_error`,durationMs:r,error:t}}let f=performance.now()-d,p=performance.now(),h;try{h=JSON.stringify(u)}catch(e){if(e instanceof RangeError)return{kind:`too_large`};throw e}if(h.length>e.responseMaxBytes)return{kind:`too_large`};let v=performance.now()-p,y=/\bgzip\b/i.test(e.acceptEncoding)&&h.length>1024,x=null,S=0;if(y){let e=performance.now();x=await m(Buffer.from(h)),S=performance.now()-e}let C=performance.now()-e.loadStartMs,w=t.solveMeta.seq===c+1?t.solveMeta.last:null,T=w&&!w.fromCache&&t.rhinoTiming.seq===l+1?t.rhinoTiming.last:null,E={"Content-Type":`application/json`,"Server-Timing":b({defLoadMs:e.defLoadMs,treeBuildMs:s,solveMs:f,serializeMs:v,gzipMs:S,serverTotalMs:C,rhino:T,settle:w,byteRefOutcome:e.byteRefOutcome,prepMarks:e.prepMarks??[]}),[g]:`1`,Vary:`Accept-Encoding`},D={treeBuildMs:s,solveMs:f,serializeMs:v,gzipMs:S,serverTotalMs:C,serializedBytes:h.length,compressedBytes:x?.byteLength??null},O;return x?(E[`Content-Encoding`]=`gzip`,E[`Content-Length`]=String(x.byteLength),O={body:new Uint8Array(x),encoding:`gzip`,headers:E,result:u,metrics:D}):(E[`Content-Length`]=String(Buffer.byteLength(h)),O={body:h,headers:E,result:u,metrics:D}),{kind:`ok`,envelope:O,solveMs:f,errorCount:u.errors?.length??0,warningCount:u.warnings?.length??0}}function y(e,t){let n=/\bgzip\b/i.test(t);if(e.encoding===`gzip`&&!n){let t=l(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 b(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+n.solve+n.encode,i=Math.max(0,e.solveMs-r);t+=`, rhino_decode;dur=${n.decode.toFixed(1)}, rhino_solve;dur=${n.solve.toFixed(1)}, rhino_encode;dur=${n.encode.toFixed(1)}, compute_link;dur=${i.toFixed(1)}`}for(let[n,r]of e.prepMarks)t+=`, p_${n};dur=${r.toFixed(1)}`;let r=e.settle;r&&(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 x(e){return e.id}const S=5e3;function C(t){let n=t.maxWarmComputeServers??16,r=new Map,i=new Map,a=new Map;t.debug===`verbose`&&s();let o=e=>{t.debug&&t.onDebugLog?.(e)};async function c(n,r){let i={last:null,seq:0},a={last:null,seq:0},s=t.debug===`verbose`,c={serverUrl:n.serverUrl,apiKey:n.apiKey,debug:s,cachesolve:t.cachesolve,cacheerroredsolves:t.cacheerroredsolves,onServerTiming:e=>{let n=e.decode??0,r=e.solve??0,a=e.encode??0;if(i.seq+=1,i.last={decode:n,solve:r,encode:a},!t.debug)return;let s=n<=1?`HIT (definition reused, no re-upload/parse)`:`miss (full parse)`,c=t.cachesolve&&r<=1?`HIT (cached result, solve skipped)`:t.cachesolve?`miss (solved fresh)`:`off`;o(`[Compute/rhino-cache] decode=${n}ms solve=${r}ms encode=${a}ms | definition-cache: ${s} | solve-cache (cachesolve): ${c}`)}};r&&(c.headers={"X-Selva-Definition":r});let l=await e.create(c),u=1,d=0,f=!1;async function p(e){if(!f){f=!0;try{let t=await Promise.resolve(l.serverStats?.getActiveChildren({initialize:!1})).catch(()=>null);d=Date.now();let n=!t||t<=0?1:t;if(n===u)return;o(`[Compute/client-cache] concurrency ${u} → ${n} (server child count)`),u=n,e?.setMaxConcurrent(n)}finally{f=!1}}}await p();let m=l.createScheduler({mode:`queue`,maxConcurrent:u,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(a.seq+=1,n.status===`success`&&(n.fromCache!==!0&&Date.now()-d>=3e5&&p(m),a.last={fromCache:n.fromCache===!0,definitionReuploaded:n.definitionReuploaded},t.debug&&(o(`[Compute/selva-cache] ${n.fromCache?`HIT — served from cache (no compute call)`:`miss — went to Rhino.Compute`} (${Math.round(n.durationMs)}ms)`),n.fromCache||o(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,r=Array.isArray(e)?e.length:0;if(r>0){let n=t.cacheerroredsolves?`cached (cacheerroredsolves on)`:`not cached (cacheerroredsolves off) — re-run until fixed`;o(`[Compute/rhino-cache] ${r} GH error(s); ${n}. First: ${JSON.stringify(e[0])}`)}}}});return{client:l,scheduler:m,rhinoTiming:i,solveMeta:a}}return{async getClient(e,t){let s=e.id,l=r.get(s);if(l)return r.delete(s),r.set(s,l),l;let u=i.get(s);if(u)return u;let d=a.get(s);if(d){if(Date.now()-d.at<S)throw o(`[Compute/client-cache] server ${s} failed to build <${S}ms ago — replaying error`),d.error;a.delete(s)}o(`[Compute/client-cache] miss — building warm client for server ${s}`);let f=(async()=>{let i;try{i=await c(e,t?.definitionGuid)}catch(e){throw a.set(s,{error:e,at:Date.now()}),e}if(r.size>=n){let e=r.keys().next().value;e!==void 0&&(o(`[Compute/client-cache] LRU evicted warm client for server ${e} (cap ${n})`),r.get(e)?.scheduler.dispose(),r.delete(e))}return r.set(s,i),i})().finally(()=>i.delete(s));return i.set(s,f),f},evict(e){let t=e;a.delete(t);let n=r.get(t);n&&(o(`[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(){o(`[Compute/client-cache] cleared solve caches on ${r.size} warm client(s)`);for(let e of r.values())e.scheduler.clearCache()},disposeAll(){o(`[Compute/client-cache] disposing all ${r.size} warm client(s)`);for(let e of r.values())e.scheduler.dispose();r.clear(),a.clear()}}}function w(e){let t=Math.max(0,Math.floor(e)),n=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 T(e={}){let t=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 E(e){return typeof e==`object`&&!!e&&!(e instanceof Uint8Array)&&`outcome`in e}function D(e){return typeof e==`object`&&!!e&&!(e instanceof Uint8Array)&&!(`outcome`in e)&&!(`key`in e)&&`versionId`in e}var O=class{limits;clientCache;byteCache;singleFlight;constructor(e){this.limits=e.limits;let t=e.logger??new f;this.clientCache=C({solveDeadlineMs:this.limits.solveDeadlineMs,cachesolve:this.limits.computeServerCachesolve,cacheerroredsolves:this.limits.computeCacheErroredSolves,reuseServerDefinitionCache:this.limits.computeReuseDefinitionCache,maxQueueDepth:this.limits.computeMaxQueueDepth,queueWaitMs:this.limits.computeQueueWaitMs,responseCacheMaxBytes:this.limits.computeSolveCacheBytes,debug:e.debug??!1,maxWarmComputeServers:e.maxWarmComputeServers,onDebugLog:e.onDebugLog??(e=>t.debug(e,{component:`Compute/client-cache`}))}),this.byteCache=w(this.limits.computeDefinitionCacheBytes),this.singleFlight=T({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:n,identity:r}=this.resolveDefinitionSource(e),a=await this.clientCache.getClient(e.server,{definitionGuid:e.definitionGuid}),o=_(e.inputs,e.values),s=`${r}:${e.server.id}:${i(o)}`,c=new AbortController,l=!1,u=()=>{l||c.abort()};e.signal.addEventListener(`abort`,u,{once:!0});let d=e.acceptEncoding??``,f;try{f=await this.singleFlight.run(s,()=>v({definitionSource:t,byteRefOutcome:n,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(f.kind!==`ok`)return f;let p=y(f.envelope,d);return{...f,envelope:{...f.envelope,body:p.body,headers:p.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(D(t)){let e=this.byteCache.getOrLoad(t.versionId,t.load);return{definitionSource:e,byteRefOutcome:e.outcome,identity:e.key}}if(E(t))return{definitionSource:t,byteRefOutcome:t.outcome,identity:t.key};if(n(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{h as COMPUTE_CONTRACT_VERSION,g as COMPUTE_VERSION_HEADER,O as SolveEngine,y as adaptEnvelopeToEncoding,_ as buildSolveInputTree,C as createClientCache,w as createDefinitionByteCache,T as createSolveCacheSingleFlight,c as isDefinitionRef,v as runSolvePipeline,x as serverIdentity,p as transformInputParameter};
|
|
1
|
+
import { GrasshopperClient, TreeBuilder, stableStringify } from "@selvajs/compute/grasshopper";
|
|
2
|
+
import { ComputeError, ErrorCodes, enableDebugLogging, isDefinitionRef, isDefinitionRef as isDefinitionRef$1 } from "@selvajs/compute/core";
|
|
3
|
+
import { gunzipSync, gzip } from "node:zlib";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { Agent, fetch } from "undici";
|
|
6
|
+
import { NoopLogger } from "@selvajs/platform";
|
|
7
|
+
// src/server/transform-input.ts
|
|
8
|
+
/** The input tree a solve sends: each input's value, or its schema default when it has none. */
|
|
9
|
+
function buildSolveInputTree(inputs, values) {
|
|
10
|
+
return TreeBuilder.fromValues(inputs.filter((input) => input.paramType).map((input) => ({
|
|
11
|
+
paramName: input.nickname,
|
|
12
|
+
paramType: input.paramType,
|
|
13
|
+
value: values[input.id] ?? input.default,
|
|
14
|
+
minimum: input.minimum,
|
|
15
|
+
maximum: input.maximum
|
|
16
|
+
})));
|
|
17
|
+
}
|
|
18
|
+
// src/server/solve-pipeline.ts
|
|
19
|
+
/**
|
|
20
|
+
* Transport-agnostic solve pipeline: given an already-resolved solve context
|
|
21
|
+
* (`.gh` bytes, input params + user values, a warm `SolveScheduler`), runs the
|
|
22
|
+
* solve and returns a discriminated {@link SolveOutcome} instead of throwing
|
|
23
|
+
* for expected failures. Auth, the database, share tokens, rate limits, and
|
|
24
|
+
* metric sinks are the calling route's job, not this file's.
|
|
25
|
+
*/
|
|
26
|
+
const gzipAsync = promisify(gzip);
|
|
27
|
+
/**
|
|
28
|
+
* Display output is base64 of already-deflated bytes, where higher levels find nothing more:
|
|
29
|
+
* level 1 lands within 1% of the default's size, in up to a fifth of the time.
|
|
30
|
+
*/
|
|
31
|
+
const GZIP_LEVEL = 1;
|
|
32
|
+
/**
|
|
33
|
+
* A scheduler cache hit returns the same response object, so a repeat solve reuses its bytes rather
|
|
34
|
+
* than paying to serialize and compress them again. Entries live exactly as long as the response.
|
|
35
|
+
* Only the gzip form is kept: browsers always take it, and the JSON string would double the memory.
|
|
36
|
+
*/
|
|
37
|
+
const gzippedBodies = /* @__PURE__ */ new WeakMap();
|
|
38
|
+
/** Compute-response contract version. Bump (and document the change) whenever the envelope's shape changes in a way a consumer could observe. */
|
|
39
|
+
const COMPUTE_CONTRACT_VERSION = 1;
|
|
40
|
+
const COMPUTE_VERSION_HEADER = "X-Selva-Compute-Version";
|
|
41
|
+
async function runSolvePipeline(args) {
|
|
42
|
+
const { client, signal } = args;
|
|
43
|
+
const treeBuildStart = performance.now();
|
|
44
|
+
const inputTree = args.inputTree ?? buildSolveInputTree(args.inputs, args.values);
|
|
45
|
+
const treeBuildMs = performance.now() - treeBuildStart;
|
|
46
|
+
let rhino = null;
|
|
47
|
+
let result;
|
|
48
|
+
let settle;
|
|
49
|
+
const solveStart = performance.now();
|
|
50
|
+
try {
|
|
51
|
+
const detail = await client.scheduler.solveDetailed(args.definitionSource, inputTree, {
|
|
52
|
+
signal,
|
|
53
|
+
selvaevents: args.selvaEvents,
|
|
54
|
+
onServerTiming: (t) => {
|
|
55
|
+
rhino = t;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
result = detail.response;
|
|
59
|
+
settle = {
|
|
60
|
+
fromCache: detail.fromCache,
|
|
61
|
+
definitionReuploaded: detail.definitionReuploaded
|
|
62
|
+
};
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const durationMs = performance.now() - solveStart;
|
|
65
|
+
const isAbort = err instanceof Error && err.name === "AbortError" || err instanceof ComputeError && err.code === ErrorCodes.ABORTED;
|
|
66
|
+
const isDeadline = err instanceof ComputeError && err.code === ErrorCodes.TIMEOUT_ERROR;
|
|
67
|
+
if (isAbort || isDeadline) {
|
|
68
|
+
if (isAbort && signal.aborted) return {
|
|
69
|
+
kind: "client_abort",
|
|
70
|
+
durationMs
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
kind: "timeout",
|
|
74
|
+
durationMs,
|
|
75
|
+
message: `Solve exceeded the ${Math.round(args.solveDeadlineMs / 1e3)}s deadline.`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (err instanceof ComputeError) {
|
|
79
|
+
if (err.code === ErrorCodes.RESPONSE_TOO_LARGE) return { kind: "too_large" };
|
|
80
|
+
if (err.code === ErrorCodes.QUEUE_FULL) return {
|
|
81
|
+
kind: "shed",
|
|
82
|
+
durationMs,
|
|
83
|
+
reason: "queue_full",
|
|
84
|
+
retryAfterSeconds: 1,
|
|
85
|
+
message: "Compute server is at capacity. Retry shortly."
|
|
86
|
+
};
|
|
87
|
+
if (err.code === ErrorCodes.QUEUE_TIMEOUT) return {
|
|
88
|
+
kind: "shed",
|
|
89
|
+
durationMs,
|
|
90
|
+
reason: "queue_timeout",
|
|
91
|
+
retryAfterSeconds: 1,
|
|
92
|
+
message: "Compute request waited too long in the queue. Retry shortly."
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
kind: "compute_error",
|
|
97
|
+
durationMs,
|
|
98
|
+
error: err
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const solveMs = performance.now() - solveStart;
|
|
102
|
+
const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
|
|
103
|
+
const remembered = clientWantsGzip ? gzippedBodies.get(result) : void 0;
|
|
104
|
+
let compressed = remembered?.body ?? null;
|
|
105
|
+
let serializedBytes = remembered?.serializedBytes ?? 0;
|
|
106
|
+
let serialized = "";
|
|
107
|
+
let serializeMs = 0;
|
|
108
|
+
let gzipMs = 0;
|
|
109
|
+
if (!remembered) {
|
|
110
|
+
const serializeStart = performance.now();
|
|
111
|
+
try {
|
|
112
|
+
serialized = JSON.stringify(result);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof RangeError) return { kind: "too_large" };
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
if (serialized.length > args.responseMaxBytes) return { kind: "too_large" };
|
|
118
|
+
serializeMs = performance.now() - serializeStart;
|
|
119
|
+
serializedBytes = serialized.length;
|
|
120
|
+
if (clientWantsGzip && serialized.length > 1024) {
|
|
121
|
+
const gzipStart = performance.now();
|
|
122
|
+
compressed = new Uint8Array(await gzipAsync(Buffer.from(serialized), { level: GZIP_LEVEL }));
|
|
123
|
+
gzipMs = performance.now() - gzipStart;
|
|
124
|
+
gzippedBodies.set(result, {
|
|
125
|
+
body: compressed,
|
|
126
|
+
serializedBytes
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const serverTotalMs = performance.now() - args.loadStartMs;
|
|
131
|
+
const headers = {
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
"Server-Timing": buildServerTiming({
|
|
134
|
+
defLoadMs: args.defLoadMs,
|
|
135
|
+
treeBuildMs,
|
|
136
|
+
solveMs,
|
|
137
|
+
serializeMs,
|
|
138
|
+
gzipMs,
|
|
139
|
+
serverTotalMs,
|
|
140
|
+
rhino,
|
|
141
|
+
settle,
|
|
142
|
+
byteRefOutcome: args.byteRefOutcome,
|
|
143
|
+
prepMarks: args.prepMarks ?? []
|
|
144
|
+
}),
|
|
145
|
+
[COMPUTE_VERSION_HEADER]: String(1),
|
|
146
|
+
Vary: "Accept-Encoding"
|
|
147
|
+
};
|
|
148
|
+
const metrics = {
|
|
149
|
+
treeBuildMs,
|
|
150
|
+
solveMs,
|
|
151
|
+
serializeMs,
|
|
152
|
+
gzipMs,
|
|
153
|
+
serverTotalMs,
|
|
154
|
+
serializedBytes,
|
|
155
|
+
compressedBytes: compressed?.byteLength ?? null
|
|
156
|
+
};
|
|
157
|
+
let envelope;
|
|
158
|
+
if (compressed) {
|
|
159
|
+
headers["Content-Encoding"] = "gzip";
|
|
160
|
+
headers["Content-Length"] = String(compressed.byteLength);
|
|
161
|
+
envelope = {
|
|
162
|
+
body: compressed,
|
|
163
|
+
encoding: "gzip",
|
|
164
|
+
headers,
|
|
165
|
+
result,
|
|
166
|
+
metrics
|
|
167
|
+
};
|
|
168
|
+
} else {
|
|
169
|
+
headers["Content-Length"] = String(Buffer.byteLength(serialized));
|
|
170
|
+
envelope = {
|
|
171
|
+
body: serialized,
|
|
172
|
+
headers,
|
|
173
|
+
result,
|
|
174
|
+
metrics
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
kind: "ok",
|
|
179
|
+
envelope,
|
|
180
|
+
solveMs,
|
|
181
|
+
errorCount: result.errors?.length ?? 0,
|
|
182
|
+
warningCount: result.warnings?.length ?? 0
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Re-key a coalesced envelope to a single waiter's `Accept-Encoding`.
|
|
187
|
+
*
|
|
188
|
+
* The single-flight coalescer runs ONE pipeline execution for N identical
|
|
189
|
+
* concurrent solves and hands every waiter the same {@link SolveEnvelope},
|
|
190
|
+
* baked from the FIRST caller's `Accept-Encoding`. A later waiter with a
|
|
191
|
+
* different `Accept-Encoding` would otherwise get a body labelled with the
|
|
192
|
+
* wrong encoding — `Vary` can't help here, since it's one shared object, not a
|
|
193
|
+
* cache lookup. Encoding is deliberately left out of the coalesce key so mixed
|
|
194
|
+
* clients still coalesce; this adapts the shared result to each waiter instead.
|
|
195
|
+
*
|
|
196
|
+
* Only the correctness-critical direction is adapted: a gzip envelope served to
|
|
197
|
+
* a non-gzip waiter is gunzipped back to JSON. The reverse (plain JSON to a
|
|
198
|
+
* gzip-capable waiter) is left uncompressed — correct, just not maximally small.
|
|
199
|
+
*/
|
|
200
|
+
function adaptEnvelopeToEncoding(envelope, acceptEncoding) {
|
|
201
|
+
const waiterWantsGzip = /\bgzip\b/i.test(acceptEncoding);
|
|
202
|
+
if (envelope.encoding === "gzip" && !waiterWantsGzip) {
|
|
203
|
+
const json = gunzipSync(envelope.body instanceof Uint8Array ? envelope.body : Buffer.from(envelope.body)).toString("utf8");
|
|
204
|
+
const headers = { ...envelope.headers };
|
|
205
|
+
delete headers["Content-Encoding"];
|
|
206
|
+
headers["Content-Length"] = String(Buffer.byteLength(json));
|
|
207
|
+
return {
|
|
208
|
+
body: json,
|
|
209
|
+
headers
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
body: envelope.body,
|
|
214
|
+
headers: envelope.headers
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* `total` is the server's headers-to-out wall time, so browser `ttfb − total`
|
|
219
|
+
* ≈ network+send latency. When the compute server reports its own timing
|
|
220
|
+
* (VektorNode fork), `rhino_*` is time on the compute server and `compute_link`
|
|
221
|
+
* is everything between it (network + queue wait). `rhino_cache` is 1 when the
|
|
222
|
+
* compute server answered from its own result cache.
|
|
223
|
+
*/
|
|
224
|
+
function buildServerTiming(parts) {
|
|
225
|
+
let header = `load;dur=${parts.defLoadMs.toFixed(1)}, tree;dur=${parts.treeBuildMs.toFixed(1)}, solve;dur=${parts.solveMs.toFixed(1)}, serialize;dur=${parts.serializeMs.toFixed(1)}, gzip;dur=${parts.gzipMs.toFixed(1)}, total;dur=${parts.serverTotalMs.toFixed(1)}`;
|
|
226
|
+
const rhino = parts.rhino;
|
|
227
|
+
if (rhino) {
|
|
228
|
+
const decode = rhino.decode ?? 0;
|
|
229
|
+
const solve = rhino.solve ?? 0;
|
|
230
|
+
const encode = rhino.encode ?? 0;
|
|
231
|
+
const computeLinkMs = Math.max(0, parts.solveMs - decode - solve - encode);
|
|
232
|
+
header += `, rhino_decode;dur=${decode.toFixed(1)}, rhino_solve;dur=${solve.toFixed(1)}, rhino_encode;dur=${encode.toFixed(1)}, compute_link;dur=${computeLinkMs.toFixed(1)}, rhino_cache;dur=${rhino.cacheHit ? 1 : 0}`;
|
|
233
|
+
if (rhino.gh !== void 0) header += `, rhino_gh;dur=${rhino.gh.toFixed(1)}`;
|
|
234
|
+
if (rhino.recomputed) header += `, rhino_recomputed;desc="${rhino.recomputed.ran}/${rhino.recomputed.total}"`;
|
|
235
|
+
}
|
|
236
|
+
for (const [label, ms] of parts.prepMarks) header += `, p_${label};dur=${ms.toFixed(1)}`;
|
|
237
|
+
const settle = parts.settle;
|
|
238
|
+
header += `, selva_cache;dur=${settle.fromCache ? 1 : 0}`;
|
|
239
|
+
if (settle.definitionReuploaded !== void 0) header += `, def_reupload;dur=${settle.definitionReuploaded ? 1 : 0}`;
|
|
240
|
+
const byteRef = parts.byteRefOutcome;
|
|
241
|
+
if (byteRef) {
|
|
242
|
+
const verdict = !byteRef.loaded ? "skipped" : byteRef.fromCache ? "hit" : "miss";
|
|
243
|
+
header += `, def_bytes;desc=${verdict}`;
|
|
244
|
+
}
|
|
245
|
+
return header;
|
|
246
|
+
}
|
|
247
|
+
// src/server/long-fetch.ts
|
|
248
|
+
/** undici's default `headersTimeout` and `bodyTimeout`, which `timeoutMs` can't extend. */
|
|
249
|
+
const UNDICI_DEFAULT_LIMIT_MS = 3e5;
|
|
250
|
+
/** Both off, not raised: the scheduler's `timeoutMs` stays the one deadline on a solve. */
|
|
251
|
+
const unlimited = new Agent({
|
|
252
|
+
headersTimeout: 0,
|
|
253
|
+
bodyTimeout: 0
|
|
254
|
+
});
|
|
255
|
+
const unlimitedFetch = ((input, init) => fetch(input, {
|
|
256
|
+
...init,
|
|
257
|
+
dispatcher: unlimited
|
|
258
|
+
}));
|
|
259
|
+
/** The fetch for a solve deadline: `undefined` means the global one reaches it. */
|
|
260
|
+
function fetchForDeadline(deadlineMs) {
|
|
261
|
+
return deadlineMs >= UNDICI_DEFAULT_LIMIT_MS ? unlimitedFetch : void 0;
|
|
262
|
+
}
|
|
263
|
+
// src/server/client-cache.ts
|
|
264
|
+
function serverIdentity(server) {
|
|
265
|
+
return server.id;
|
|
266
|
+
}
|
|
267
|
+
const DEFAULT_MAX_WARM_COMPUTE_SERVERS = 16;
|
|
268
|
+
/**
|
|
269
|
+
* Fallback concurrency while the first probe is still in flight, and the value
|
|
270
|
+
* kept whenever a probe fails or reports 0 — never send more than a single
|
|
271
|
+
* solve to a server whose real capacity is unknown.
|
|
272
|
+
*/
|
|
273
|
+
const FALLBACK_CONCURRENCY = 1;
|
|
274
|
+
/**
|
|
275
|
+
* How stale the compute server's child count may get before a solve triggers a
|
|
276
|
+
* re-read. Short enough that a resized pool is picked up within a few solves,
|
|
277
|
+
* long enough that a busy server is probed rarely — the probe rides on solve
|
|
278
|
+
* completion, so an idle server is never polled at all.
|
|
279
|
+
*/
|
|
280
|
+
const CONCURRENCY_PROBE_INTERVAL_MS = 3e5;
|
|
281
|
+
/**
|
|
282
|
+
* How long a failed build is remembered before the next `getClient` retries the
|
|
283
|
+
* handshake. Without this, every solve against a down server pays the full probe
|
|
284
|
+
* ladder again, so a user clicking Solve twice waits twice.
|
|
285
|
+
*
|
|
286
|
+
* Short on purpose: this is a convenience for the rapid-retry case, not a
|
|
287
|
+
* circuit breaker. An operator who starts the compute VM should not have to wait
|
|
288
|
+
* out a long penalty window before the app notices.
|
|
289
|
+
*/
|
|
290
|
+
const FAILED_BUILD_TTL_MS = 5e3;
|
|
291
|
+
function createClientCache(config) {
|
|
292
|
+
const maxWarmComputeServers = config.maxWarmComputeServers ?? DEFAULT_MAX_WARM_COMPUTE_SERVERS;
|
|
293
|
+
const cache = /* @__PURE__ */ new Map();
|
|
294
|
+
const pending = /* @__PURE__ */ new Map();
|
|
295
|
+
const failed = /* @__PURE__ */ new Map();
|
|
296
|
+
if (config.debug === "verbose") enableDebugLogging();
|
|
297
|
+
const debugLog = (message) => {
|
|
298
|
+
if (config.debug) config.onDebugLog?.(message);
|
|
299
|
+
};
|
|
300
|
+
async function build(server, definitionGuid) {
|
|
301
|
+
const debugVerbose = config.debug === "verbose";
|
|
302
|
+
const longFetch = fetchForDeadline(config.solveDeadlineMs);
|
|
303
|
+
const clientConfig = {
|
|
304
|
+
serverUrl: server.serverUrl,
|
|
305
|
+
apiKey: server.apiKey,
|
|
306
|
+
debug: debugVerbose,
|
|
307
|
+
cachesolve: config.cachesolve,
|
|
308
|
+
maxResponseBytes: config.maxResponseBytes,
|
|
309
|
+
...longFetch && { fetch: longFetch }
|
|
310
|
+
};
|
|
311
|
+
if (config.debug) clientConfig.onServerTiming = (t) => {
|
|
312
|
+
if (t.cacheHit) {
|
|
313
|
+
debugLog("[Compute/rhino-cache] solve-cache (cachesolve): HIT (cached result, solve skipped)");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const decode = t.decode ?? 0;
|
|
317
|
+
const solve = t.solve ?? 0;
|
|
318
|
+
const encode = t.encode ?? 0;
|
|
319
|
+
const defCache = decode <= 1 ? "HIT (definition reused, no re-upload/parse)" : "miss (full parse)";
|
|
320
|
+
const solveCache = config.cachesolve ? "miss (solved fresh)" : "off";
|
|
321
|
+
const ran = t.recomputed ? ` | ran ${t.recomputed.ran}/${t.recomputed.total} objects` : "";
|
|
322
|
+
debugLog(`[Compute/rhino-cache] decode=${decode}ms solve=${solve}ms encode=${encode}ms | definition-cache: ${defCache} | solve-cache (cachesolve): ${solveCache}${ran}`);
|
|
323
|
+
};
|
|
324
|
+
if (definitionGuid) clientConfig.headers = { "X-Selva-Definition": definitionGuid };
|
|
325
|
+
const client = await GrasshopperClient.create(clientConfig);
|
|
326
|
+
let maxConcurrent = FALLBACK_CONCURRENCY;
|
|
327
|
+
let lastProbeAt = 0;
|
|
328
|
+
let probing = false;
|
|
329
|
+
/**
|
|
330
|
+
* Best-effort re-read of the server's child count. Never awaited by a caller:
|
|
331
|
+
* a probe must not add a round-trip to the solve path, so a changed count takes
|
|
332
|
+
* effect on the NEXT solve rather than the one that triggered the probe.
|
|
333
|
+
*
|
|
334
|
+
* `initialize: false` keeps this from spawning children on an idle server —
|
|
335
|
+
* which also means a cold server honestly reports 0. Both a failed probe and a
|
|
336
|
+
* 0 result fall back to `FALLBACK_CONCURRENCY` (1): an unknown-capacity server
|
|
337
|
+
* is safer under-used than oversent.
|
|
338
|
+
*/
|
|
339
|
+
async function refreshConcurrency(scheduler) {
|
|
340
|
+
if (probing) return;
|
|
341
|
+
probing = true;
|
|
342
|
+
try {
|
|
343
|
+
const children = await Promise.resolve(client.serverStats?.getActiveChildren({ initialize: false })).catch(() => null);
|
|
344
|
+
lastProbeAt = Date.now();
|
|
345
|
+
const next = !children || children <= 0 ? FALLBACK_CONCURRENCY : children;
|
|
346
|
+
if (next === maxConcurrent) return;
|
|
347
|
+
debugLog(`[Compute/client-cache] concurrency ${maxConcurrent} → ${next} (server child count)`);
|
|
348
|
+
maxConcurrent = next;
|
|
349
|
+
scheduler?.setMaxConcurrent(next);
|
|
350
|
+
} finally {
|
|
351
|
+
probing = false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
await refreshConcurrency();
|
|
355
|
+
const scheduler = client.createScheduler({
|
|
356
|
+
mode: "queue",
|
|
357
|
+
maxConcurrent,
|
|
358
|
+
maxQueueDepth: config.maxQueueDepth || void 0,
|
|
359
|
+
queueWaitMs: config.queueWaitMs || void 0,
|
|
360
|
+
timeoutMs: config.solveDeadlineMs,
|
|
361
|
+
cache: config.responseCacheMaxBytes > 0 ? { maxBytes: config.responseCacheMaxBytes } : false,
|
|
362
|
+
reuseServerDefinitionCache: config.reuseServerDefinitionCache,
|
|
363
|
+
onSettle: (_ctx, result) => {
|
|
364
|
+
if (result.status !== "success") return;
|
|
365
|
+
if (result.fromCache !== true && Date.now() - lastProbeAt >= CONCURRENCY_PROBE_INTERVAL_MS) refreshConcurrency(scheduler);
|
|
366
|
+
if (!config.debug) return;
|
|
367
|
+
debugLog(`[Compute/selva-cache] ${result.fromCache ? "HIT — served from cache (no compute call)" : "miss — went to Rhino.Compute"} (${Math.round(result.durationMs)}ms)`);
|
|
368
|
+
if (!result.fromCache) debugLog(result.definitionReuploaded === true ? "[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)" : result.definitionReuploaded === false ? "[Compute/def-cache] HIT — definition reused from server cache (no upload)" : "[Compute/def-cache] n/a — reuse disabled or non-reusable definition");
|
|
369
|
+
if (!result.fromCache) {
|
|
370
|
+
const errs = result.response?.errors;
|
|
371
|
+
const errCount = Array.isArray(errs) ? errs.length : 0;
|
|
372
|
+
if (errCount > 0) debugLog(`[Compute/rhino-cache] ${errCount} GH error(s); not cached by Rhino.Compute. First: ${JSON.stringify(errs[0])}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
client,
|
|
378
|
+
scheduler
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
async getClient(server, opts) {
|
|
383
|
+
const key = server.id;
|
|
384
|
+
const existing = cache.get(key);
|
|
385
|
+
if (existing) {
|
|
386
|
+
cache.delete(key);
|
|
387
|
+
cache.set(key, existing);
|
|
388
|
+
return existing;
|
|
389
|
+
}
|
|
390
|
+
const inFlight = pending.get(key);
|
|
391
|
+
if (inFlight) return inFlight;
|
|
392
|
+
const recentFailure = failed.get(key);
|
|
393
|
+
if (recentFailure) {
|
|
394
|
+
if (Date.now() - recentFailure.at < FAILED_BUILD_TTL_MS) {
|
|
395
|
+
debugLog(`[Compute/client-cache] server ${key} failed to build <${FAILED_BUILD_TTL_MS}ms ago — replaying error`);
|
|
396
|
+
throw recentFailure.error;
|
|
397
|
+
}
|
|
398
|
+
failed.delete(key);
|
|
399
|
+
}
|
|
400
|
+
debugLog(`[Compute/client-cache] miss — building warm client for server ${key}`);
|
|
401
|
+
const buildPromise = (async () => {
|
|
402
|
+
let entry;
|
|
403
|
+
try {
|
|
404
|
+
entry = await build(server, opts?.definitionGuid);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
failed.set(key, {
|
|
407
|
+
error: err,
|
|
408
|
+
at: Date.now()
|
|
409
|
+
});
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
if (cache.size >= maxWarmComputeServers) {
|
|
413
|
+
const oldestKey = cache.keys().next().value;
|
|
414
|
+
if (oldestKey !== void 0) {
|
|
415
|
+
debugLog(`[Compute/client-cache] LRU evicted warm client for server ${oldestKey} (cap ${maxWarmComputeServers})`);
|
|
416
|
+
cache.get(oldestKey)?.scheduler.dispose();
|
|
417
|
+
cache.delete(oldestKey);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
cache.set(key, entry);
|
|
421
|
+
return entry;
|
|
422
|
+
})().finally(() => pending.delete(key));
|
|
423
|
+
pending.set(key, buildPromise);
|
|
424
|
+
return buildPromise;
|
|
425
|
+
},
|
|
426
|
+
evict(id) {
|
|
427
|
+
const key = id;
|
|
428
|
+
failed.delete(key);
|
|
429
|
+
const entry = cache.get(key);
|
|
430
|
+
if (entry) {
|
|
431
|
+
debugLog(`[Compute/client-cache] evicted warm client for server ${key} (config change)`);
|
|
432
|
+
entry.scheduler.dispose();
|
|
433
|
+
cache.delete(key);
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
solveCacheStats() {
|
|
437
|
+
const total = {
|
|
438
|
+
warmClients: cache.size,
|
|
439
|
+
entries: 0,
|
|
440
|
+
bytes: 0,
|
|
441
|
+
hits: 0,
|
|
442
|
+
misses: 0,
|
|
443
|
+
evictions: 0
|
|
444
|
+
};
|
|
445
|
+
for (const entry of cache.values()) {
|
|
446
|
+
const s = entry.scheduler.cacheStats();
|
|
447
|
+
total.entries += s.entries;
|
|
448
|
+
total.bytes += s.bytes;
|
|
449
|
+
total.hits += s.hits;
|
|
450
|
+
total.misses += s.misses;
|
|
451
|
+
total.evictions += s.evictions;
|
|
452
|
+
}
|
|
453
|
+
return total;
|
|
454
|
+
},
|
|
455
|
+
clearSolveCaches() {
|
|
456
|
+
debugLog(`[Compute/client-cache] cleared solve caches on ${cache.size} warm client(s)`);
|
|
457
|
+
for (const entry of cache.values()) entry.scheduler.clearCache();
|
|
458
|
+
},
|
|
459
|
+
disposeAll() {
|
|
460
|
+
debugLog(`[Compute/client-cache] disposing all ${cache.size} warm client(s)`);
|
|
461
|
+
for (const entry of cache.values()) entry.scheduler.dispose();
|
|
462
|
+
cache.clear();
|
|
463
|
+
failed.clear();
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
// src/server/definition-byte-cache.ts
|
|
468
|
+
/**
|
|
469
|
+
* @param maxBytes total retained-byte budget; `0` (or negative) disables caching
|
|
470
|
+
* entirely — every `load()` calls the loader and nothing is retained.
|
|
471
|
+
*/
|
|
472
|
+
function createDefinitionByteCache(maxBytes) {
|
|
473
|
+
const budget = Math.max(0, Math.floor(maxBytes));
|
|
474
|
+
const cache = /* @__PURE__ */ new Map();
|
|
475
|
+
let retainedBytes = 0;
|
|
476
|
+
const stats = {
|
|
477
|
+
hits: 0,
|
|
478
|
+
misses: 0,
|
|
479
|
+
evictions: 0
|
|
480
|
+
};
|
|
481
|
+
function touch(key, entry) {
|
|
482
|
+
cache.delete(key);
|
|
483
|
+
cache.set(key, entry);
|
|
484
|
+
}
|
|
485
|
+
function evictToFit() {
|
|
486
|
+
while (retainedBytes > budget && cache.size > 0) {
|
|
487
|
+
const oldestKey = cache.keys().next().value;
|
|
488
|
+
const oldest = cache.get(oldestKey);
|
|
489
|
+
cache.delete(oldestKey);
|
|
490
|
+
retainedBytes -= oldest.bytes.byteLength;
|
|
491
|
+
stats.evictions += 1;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function store(key, bytes) {
|
|
495
|
+
if (budget === 0) return;
|
|
496
|
+
if (bytes.byteLength > budget) return;
|
|
497
|
+
const existing = cache.get(key);
|
|
498
|
+
if (existing) retainedBytes -= existing.bytes.byteLength;
|
|
499
|
+
cache.set(key, { bytes });
|
|
500
|
+
retainedBytes += bytes.byteLength;
|
|
501
|
+
evictToFit();
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
getOrLoad(versionId, load) {
|
|
505
|
+
const outcome = {
|
|
506
|
+
loaded: false,
|
|
507
|
+
fromCache: false
|
|
508
|
+
};
|
|
509
|
+
return {
|
|
510
|
+
key: versionId,
|
|
511
|
+
outcome,
|
|
512
|
+
load: async () => {
|
|
513
|
+
const hit = cache.get(versionId);
|
|
514
|
+
if (hit) {
|
|
515
|
+
touch(versionId, hit);
|
|
516
|
+
stats.hits += 1;
|
|
517
|
+
outcome.loaded = true;
|
|
518
|
+
outcome.fromCache = true;
|
|
519
|
+
return hit.bytes;
|
|
520
|
+
}
|
|
521
|
+
stats.misses += 1;
|
|
522
|
+
outcome.loaded = true;
|
|
523
|
+
outcome.fromCache = false;
|
|
524
|
+
const bytes = await load();
|
|
525
|
+
store(versionId, bytes);
|
|
526
|
+
return bytes;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
stats() {
|
|
531
|
+
return {
|
|
532
|
+
hits: stats.hits,
|
|
533
|
+
misses: stats.misses,
|
|
534
|
+
evictions: stats.evictions,
|
|
535
|
+
entries: cache.size,
|
|
536
|
+
bytes: retainedBytes
|
|
537
|
+
};
|
|
538
|
+
},
|
|
539
|
+
clear() {
|
|
540
|
+
cache.clear();
|
|
541
|
+
retainedBytes = 0;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
// src/server/solve-cache-single-flight.ts
|
|
546
|
+
function createSolveCacheSingleFlight(options = {}) {
|
|
547
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
548
|
+
return {
|
|
549
|
+
run(key, work, onWaiterJoined) {
|
|
550
|
+
const existing = inflight.get(key);
|
|
551
|
+
if (existing) {
|
|
552
|
+
options.onJoin?.(key);
|
|
553
|
+
existing.notifyOwner?.();
|
|
554
|
+
return existing.promise;
|
|
555
|
+
}
|
|
556
|
+
const flight = {
|
|
557
|
+
notifyOwner: onWaiterJoined,
|
|
558
|
+
promise: void 0
|
|
559
|
+
};
|
|
560
|
+
const p = (async () => work())().finally(() => {
|
|
561
|
+
flight.notifyOwner = void 0;
|
|
562
|
+
if (inflight.get(key) === flight) inflight.delete(key);
|
|
563
|
+
});
|
|
564
|
+
flight.promise = p;
|
|
565
|
+
inflight.set(key, flight);
|
|
566
|
+
return p;
|
|
567
|
+
},
|
|
568
|
+
inFlight() {
|
|
569
|
+
return inflight.size;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// src/server/solve-engine.ts
|
|
574
|
+
/**
|
|
575
|
+
* Facade over the warm-client cache, definition-byte cache, single-flight
|
|
576
|
+
* coalescing, and the solve pipeline — the primitives an app route would
|
|
577
|
+
* otherwise wire up by hand.
|
|
578
|
+
*
|
|
579
|
+
* Doesn't read env itself: `@selvajs/server` owns `resolveComputeLimits` and
|
|
580
|
+
* depends on `@selvajs/solve`, so calling it from here would be a circular
|
|
581
|
+
* import. Callers resolve their own limits and pass in the fields below.
|
|
582
|
+
*
|
|
583
|
+
* Stays out of auth, DB reads, share tokens, rate limiting, metrics, and
|
|
584
|
+
* compute-server selection — all app policy, supplied per-call via
|
|
585
|
+
* `SolveEngineSolveArgs.server`.
|
|
586
|
+
*/
|
|
587
|
+
function isByteCacheRef(source) {
|
|
588
|
+
return typeof source === "object" && source !== null && !(source instanceof Uint8Array) && "outcome" in source;
|
|
589
|
+
}
|
|
590
|
+
function isVersionLoadPair(source) {
|
|
591
|
+
return typeof source === "object" && source !== null && !(source instanceof Uint8Array) && !("outcome" in source) && !("key" in source) && "versionId" in source;
|
|
592
|
+
}
|
|
593
|
+
var SolveEngine = class {
|
|
594
|
+
limits;
|
|
595
|
+
clientCache;
|
|
596
|
+
byteCache;
|
|
597
|
+
singleFlight;
|
|
598
|
+
constructor(options) {
|
|
599
|
+
this.limits = options.limits;
|
|
600
|
+
const logger = options.logger ?? new NoopLogger();
|
|
601
|
+
this.clientCache = createClientCache({
|
|
602
|
+
solveDeadlineMs: this.limits.solveDeadlineMs,
|
|
603
|
+
cachesolve: this.limits.computeServerCachesolve,
|
|
604
|
+
reuseServerDefinitionCache: this.limits.computeReuseDefinitionCache,
|
|
605
|
+
maxQueueDepth: this.limits.computeMaxQueueDepth,
|
|
606
|
+
queueWaitMs: this.limits.computeQueueWaitMs,
|
|
607
|
+
responseCacheMaxBytes: this.limits.computeSolveCacheBytes,
|
|
608
|
+
maxResponseBytes: this.limits.computeResponseMaxBytes,
|
|
609
|
+
debug: options.debug ?? false,
|
|
610
|
+
maxWarmComputeServers: options.maxWarmComputeServers,
|
|
611
|
+
onDebugLog: options.onDebugLog ?? ((message) => logger.debug(message, { component: "Compute/client-cache" }))
|
|
612
|
+
});
|
|
613
|
+
this.byteCache = createDefinitionByteCache(this.limits.computeDefinitionCacheBytes);
|
|
614
|
+
this.singleFlight = createSolveCacheSingleFlight({ onJoin: options.onSolveCoalesced });
|
|
615
|
+
}
|
|
616
|
+
getClient(server, opts) {
|
|
617
|
+
return this.clientCache.getClient(server, opts);
|
|
618
|
+
}
|
|
619
|
+
definitionRef(versionId, load) {
|
|
620
|
+
return this.byteCache.getOrLoad(versionId, load);
|
|
621
|
+
}
|
|
622
|
+
evictServer(id) {
|
|
623
|
+
this.clientCache.evict(id);
|
|
624
|
+
}
|
|
625
|
+
clearSolveCaches() {
|
|
626
|
+
this.clientCache.clearSolveCaches();
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Drop every retained `.gh` byte entry. Safe at any time: entries key on an
|
|
630
|
+
* immutable version id, so clearing costs a storage re-read and nothing else.
|
|
631
|
+
*/
|
|
632
|
+
clearDefinitionCache() {
|
|
633
|
+
this.byteCache.clear();
|
|
634
|
+
}
|
|
635
|
+
stats() {
|
|
636
|
+
return {
|
|
637
|
+
client: this.clientCache.solveCacheStats(),
|
|
638
|
+
definitionBytes: this.byteCache.stats(),
|
|
639
|
+
coalescing: { inFlight: this.singleFlight.inFlight() }
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
async solve(args) {
|
|
643
|
+
const { definitionSource: resolvedSource, byteRefOutcome, identity } = this.resolveDefinitionSource(args);
|
|
644
|
+
const client = await this.clientCache.getClient(args.server, { definitionGuid: args.definitionGuid });
|
|
645
|
+
const inputTree = buildSolveInputTree(args.inputs, args.values);
|
|
646
|
+
const coalesceKey = `${identity}:${args.server.id}:${stableStringify(inputTree)}`;
|
|
647
|
+
const abortController = new AbortController();
|
|
648
|
+
let hasWaiters = false;
|
|
649
|
+
const onCallerAbort = () => {
|
|
650
|
+
if (!hasWaiters) abortController.abort();
|
|
651
|
+
};
|
|
652
|
+
args.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
653
|
+
const acceptEncoding = args.acceptEncoding ?? "";
|
|
654
|
+
let outcome;
|
|
655
|
+
try {
|
|
656
|
+
outcome = await this.singleFlight.run(coalesceKey, () => runSolvePipeline({
|
|
657
|
+
definitionSource: resolvedSource,
|
|
658
|
+
byteRefOutcome,
|
|
659
|
+
inputs: args.inputs,
|
|
660
|
+
values: args.values,
|
|
661
|
+
inputTree,
|
|
662
|
+
client,
|
|
663
|
+
responseMaxBytes: this.limits.computeResponseMaxBytes,
|
|
664
|
+
solveDeadlineMs: this.limits.solveDeadlineMs,
|
|
665
|
+
acceptEncoding,
|
|
666
|
+
selvaEvents: args.selvaEvents,
|
|
667
|
+
signal: abortController.signal,
|
|
668
|
+
loadStartMs: args.loadStartMs ?? performance.now(),
|
|
669
|
+
defLoadMs: args.defLoadMs ?? 0,
|
|
670
|
+
prepMarks: args.prepMarks
|
|
671
|
+
}), () => {
|
|
672
|
+
hasWaiters = true;
|
|
673
|
+
});
|
|
674
|
+
} finally {
|
|
675
|
+
args.signal.removeEventListener("abort", onCallerAbort);
|
|
676
|
+
}
|
|
677
|
+
if (outcome.kind !== "ok") return outcome;
|
|
678
|
+
const wire = adaptEnvelopeToEncoding(outcome.envelope, acceptEncoding);
|
|
679
|
+
return {
|
|
680
|
+
...outcome,
|
|
681
|
+
envelope: {
|
|
682
|
+
...outcome.envelope,
|
|
683
|
+
body: wire.body,
|
|
684
|
+
headers: wire.headers
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
toResponse(outcome, opts) {
|
|
689
|
+
const fail = (status, body) => {
|
|
690
|
+
if (opts?.onError) opts.onError(status, body);
|
|
691
|
+
const headers = { "Content-Type": "application/json" };
|
|
692
|
+
if (body.retryAfter !== void 0) headers["Retry-After"] = String(body.retryAfter);
|
|
693
|
+
return {
|
|
694
|
+
status,
|
|
695
|
+
headers,
|
|
696
|
+
body: JSON.stringify(body)
|
|
697
|
+
};
|
|
698
|
+
};
|
|
699
|
+
switch (outcome.kind) {
|
|
700
|
+
case "ok": return {
|
|
701
|
+
status: 200,
|
|
702
|
+
headers: outcome.envelope.headers,
|
|
703
|
+
body: outcome.envelope.body
|
|
704
|
+
};
|
|
705
|
+
case "timeout": return fail(504, { message: outcome.message });
|
|
706
|
+
case "client_abort": return fail(499, { message: "Client closed request" });
|
|
707
|
+
case "too_large": return fail(413, { message: "Solve result is too large to return. This usually means a file output exceeds the supported size." });
|
|
708
|
+
case "shed": return fail(503, {
|
|
709
|
+
message: outcome.message,
|
|
710
|
+
retryAfter: outcome.retryAfterSeconds
|
|
711
|
+
});
|
|
712
|
+
case "compute_error": throw outcome.error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
toWebResponse(outcome) {
|
|
716
|
+
const r = this.toResponse(outcome);
|
|
717
|
+
return new Response(typeof r.body === "string" ? r.body : new Uint8Array(r.body), {
|
|
718
|
+
status: r.status,
|
|
719
|
+
headers: r.headers
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
resolveDefinitionSource(args) {
|
|
723
|
+
const source = args.definitionSource;
|
|
724
|
+
if (isVersionLoadPair(source)) {
|
|
725
|
+
const ref = this.byteCache.getOrLoad(source.versionId, source.load);
|
|
726
|
+
return {
|
|
727
|
+
definitionSource: ref,
|
|
728
|
+
byteRefOutcome: ref.outcome,
|
|
729
|
+
identity: ref.key
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (isByteCacheRef(source)) return {
|
|
733
|
+
definitionSource: source,
|
|
734
|
+
byteRefOutcome: source.outcome,
|
|
735
|
+
identity: source.key
|
|
736
|
+
};
|
|
737
|
+
if (isDefinitionRef$1(source)) return {
|
|
738
|
+
definitionSource: source,
|
|
739
|
+
identity: source.key
|
|
740
|
+
};
|
|
741
|
+
if (!args.definitionKey) throw new Error("SolveEngine.solve(): definitionKey is required when definitionSource is raw bytes or a string.");
|
|
742
|
+
return {
|
|
743
|
+
definitionSource: source,
|
|
744
|
+
identity: args.definitionKey
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
export { COMPUTE_CONTRACT_VERSION, COMPUTE_VERSION_HEADER, SolveEngine, adaptEnvelopeToEncoding, buildSolveInputTree, createClientCache, createDefinitionByteCache, createSolveCacheSingleFlight, isDefinitionRef, runSolvePipeline, serverIdentity };
|
|
749
|
+
|
|
2
750
|
//# sourceMappingURL=server.js.map
|