@selvajs/server 0.2.0-beta.1 → 0.2.0-beta.3
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/access/__tests__/project-access-input.test.d.ts +2 -0
- package/dist/access/__tests__/project-access-input.test.d.ts.map +1 -0
- package/dist/access/__tests__/project-access-input.test.js +146 -0
- package/dist/access/__tests__/project-access-input.test.js.map +1 -0
- package/dist/access/index.d.ts +2 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +4 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/project-access-input.d.ts +52 -0
- package/dist/access/project-access-input.d.ts.map +1 -0
- package/dist/access/project-access-input.js +56 -0
- package/dist/access/project-access-input.js.map +1 -0
- package/dist/compute/__tests__/client-cache.test.js +70 -1
- package/dist/compute/__tests__/client-cache.test.js.map +1 -1
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts +2 -0
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +1 -0
- package/dist/compute/__tests__/definition-byte-cache.test.js +107 -0
- package/dist/compute/__tests__/definition-byte-cache.test.js.map +1 -0
- package/dist/compute/__tests__/rate-limit.test.js +36 -0
- package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
- package/dist/compute/__tests__/solve-cache.test.d.ts +13 -0
- package/dist/compute/__tests__/solve-cache.test.d.ts.map +1 -0
- package/dist/compute/__tests__/solve-cache.test.js +234 -0
- package/dist/compute/__tests__/solve-cache.test.js.map +1 -0
- package/dist/compute/__tests__/solve-pipeline.test.js +327 -15
- package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
- package/dist/compute/__tests__/transform-input.test.js +7 -1
- package/dist/compute/__tests__/transform-input.test.js.map +1 -1
- package/dist/compute/client-cache.d.ts +32 -8
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +24 -6
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/definition-byte-cache.d.ts +85 -0
- package/dist/compute/definition-byte-cache.d.ts.map +1 -0
- package/dist/compute/definition-byte-cache.js +110 -0
- package/dist/compute/definition-byte-cache.js.map +1 -0
- package/dist/compute/index.d.ts +7 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +6 -1
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +62 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +35 -1
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/memory-solve-cache.d.ts +58 -0
- package/dist/compute/memory-solve-cache.d.ts.map +1 -0
- package/dist/compute/memory-solve-cache.js +0 -0
- package/dist/compute/memory-solve-cache.js.map +1 -0
- package/dist/compute/rate-limit.d.ts +12 -0
- package/dist/compute/rate-limit.d.ts.map +1 -1
- package/dist/compute/rate-limit.js +18 -0
- package/dist/compute/rate-limit.js.map +1 -1
- package/dist/compute/solve-cache-envelope.d.ts +53 -0
- package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
- package/dist/compute/solve-cache-envelope.js +66 -0
- package/dist/compute/solve-cache-envelope.js.map +1 -0
- package/dist/compute/solve-cache-key.d.ts +65 -0
- package/dist/compute/solve-cache-key.d.ts.map +1 -0
- package/dist/compute/solve-cache-key.js +50 -0
- package/dist/compute/solve-cache-key.js.map +1 -0
- package/dist/compute/solve-cache-single-flight.d.ts +35 -0
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
- package/dist/compute/solve-cache-single-flight.js +48 -0
- package/dist/compute/solve-cache-single-flight.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +71 -8
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +198 -15
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/compute/transform-input.d.ts.map +1 -1
- package/dist/compute/transform-input.js +21 -6
- package/dist/compute/transform-input.js.map +1 -1
- package/dist/definitions/__tests__/definition-service.test.d.ts +14 -0
- package/dist/definitions/__tests__/definition-service.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/definition-service.test.js +95 -0
- package/dist/definitions/__tests__/definition-service.test.js.map +1 -0
- package/dist/definitions/definition-service.d.ts +1 -1
- package/dist/definitions/definition-service.d.ts.map +1 -1
- package/dist/definitions/definition-service.js +8 -4
- package/dist/definitions/definition-service.js.map +1 -1
- package/dist/errors/SentryErrorReporter.d.ts +17 -0
- package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
- package/dist/errors/SentryErrorReporter.js +60 -0
- package/dist/errors/SentryErrorReporter.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/http/__tests__/body-size.test.d.ts +2 -0
- package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
- package/dist/http/__tests__/body-size.test.js +24 -0
- package/dist/http/__tests__/body-size.test.js.map +1 -0
- package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
- package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
- package/dist/http/__tests__/route-classifier.test.js +54 -0
- package/dist/http/__tests__/route-classifier.test.js.map +1 -0
- package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
- package/dist/http/__tests__/safe-redirect.test.js +44 -0
- package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
- package/dist/http/__tests__/security-headers.test.d.ts +2 -0
- package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
- package/dist/http/__tests__/security-headers.test.js +24 -0
- package/dist/http/__tests__/security-headers.test.js.map +1 -0
- package/dist/http/body-size.d.ts +21 -0
- package/dist/http/body-size.d.ts.map +1 -0
- package/dist/http/body-size.js +20 -0
- package/dist/http/body-size.js.map +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +6 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/redirect.d.ts +11 -0
- package/dist/http/redirect.d.ts.map +1 -0
- package/dist/http/redirect.js +20 -0
- package/dist/http/redirect.js.map +1 -0
- package/dist/http/route-classifier.d.ts +47 -0
- package/dist/http/route-classifier.d.ts.map +1 -0
- package/dist/http/route-classifier.js +37 -0
- package/dist/http/route-classifier.js.map +1 -0
- package/dist/http/security-headers.d.ts +31 -0
- package/dist/http/security-headers.d.ts.map +1 -0
- package/dist/http/security-headers.js +31 -0
- package/dist/http/security-headers.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/ops/__tests__/semver.test.d.ts +2 -0
- package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
- package/dist/ops/__tests__/semver.test.js +38 -0
- package/dist/ops/__tests__/semver.test.js.map +1 -0
- package/dist/ops/index.d.ts +2 -0
- package/dist/ops/index.d.ts.map +1 -0
- package/dist/ops/index.js +3 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/ops/semver.d.ts +35 -0
- package/dist/ops/semver.d.ts.map +1 -0
- package/dist/ops/semver.js +55 -0
- package/dist/ops/semver.js.map +1 -0
- package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
- package/dist/tokens/__tests__/token-codec.test.js +50 -0
- package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +3 -0
- package/dist/tokens/index.js.map +1 -0
- package/dist/tokens/token-codec.d.ts +54 -0
- package/dist/tokens/token-codec.d.ts.map +1 -0
- package/dist/tokens/token-codec.js +62 -0
- package/dist/tokens/token-codec.js.map +1 -0
- package/package.json +35 -2
- package/src/access/__tests__/project-access-input.test.ts +175 -0
- package/src/access/index.ts +9 -0
- package/src/access/project-access-input.ts +145 -0
- package/src/compute/__tests__/client-cache.test.ts +81 -1
- package/src/compute/__tests__/definition-byte-cache.test.ts +129 -0
- package/src/compute/__tests__/rate-limit.test.ts +37 -0
- package/src/compute/__tests__/solve-cache.test.ts +268 -0
- package/src/compute/__tests__/solve-pipeline.test.ts +380 -14
- package/src/compute/__tests__/transform-input.test.ts +8 -1
- package/src/compute/client-cache.ts +59 -16
- package/src/compute/definition-byte-cache.ts +168 -0
- package/src/compute/index.ts +34 -0
- package/src/compute/limits.ts +91 -1
- package/src/compute/memory-solve-cache.ts +0 -0
- package/src/compute/rate-limit.ts +27 -0
- package/src/compute/solve-cache-envelope.ts +98 -0
- package/src/compute/solve-cache-key.ts +85 -0
- package/src/compute/solve-cache-single-flight.ts +59 -0
- package/src/compute/solve-pipeline.ts +301 -22
- package/src/compute/transform-input.ts +20 -6
- package/src/definitions/__tests__/definition-service.test.ts +131 -0
- package/src/definitions/definition-service.ts +8 -4
- package/src/errors/SentryErrorReporter.ts +88 -0
- package/src/errors/index.ts +3 -0
- package/src/http/__tests__/body-size.test.ts +27 -0
- package/src/http/__tests__/route-classifier.test.ts +60 -0
- package/src/http/__tests__/safe-redirect.test.ts +47 -0
- package/src/http/__tests__/security-headers.test.ts +29 -0
- package/src/http/body-size.ts +24 -0
- package/src/http/index.ts +10 -0
- package/src/http/redirect.ts +16 -0
- package/src/http/route-classifier.ts +74 -0
- package/src/http/security-headers.ts +42 -0
- package/src/index.ts +11 -2
- package/src/ops/__tests__/semver.test.ts +42 -0
- package/src/ops/index.ts +3 -0
- package/src/ops/semver.ts +60 -0
- package/src/tokens/__tests__/token-codec.test.ts +63 -0
- package/src/tokens/index.ts +8 -0
- package/src/tokens/token-codec.ts +86 -0
|
@@ -13,25 +13,69 @@ import { gunzipSync } from 'node:zlib';
|
|
|
13
13
|
import type { GrasshopperClient, InputParam, SolveScheduler } from '@selvajs/compute';
|
|
14
14
|
|
|
15
15
|
// --- Mock @selvajs/compute -------------------------------------------------
|
|
16
|
+
// The factory is hoisted above module top-level, so the RhinoComputeError
|
|
17
|
+
// stand-in is declared INSIDE it. The pipeline classifies shed outcomes with
|
|
18
|
+
// `err instanceof RhinoComputeError && err.code === ErrorCodes.QUEUE_*`, so the
|
|
19
|
+
// mock must export a real class + the code constants. Tests import the mocked
|
|
20
|
+
// `RhinoComputeError` below to construct instances the pipeline recognizes.
|
|
16
21
|
vi.mock('@selvajs/compute', () => {
|
|
22
|
+
class RhinoComputeError extends Error {
|
|
23
|
+
code: string;
|
|
24
|
+
statusCode?: number;
|
|
25
|
+
context?: Record<string, unknown>;
|
|
26
|
+
constructor(
|
|
27
|
+
message: string,
|
|
28
|
+
code: string,
|
|
29
|
+
options?: { statusCode?: number; context?: Record<string, unknown> }
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'RhinoComputeError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.statusCode = options?.statusCode;
|
|
35
|
+
this.context = options?.context;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
17
38
|
return {
|
|
18
39
|
TreeBuilder: {
|
|
19
40
|
fromInputParams: (params: unknown[]) => ({ __tree: true, count: params.length })
|
|
20
41
|
},
|
|
21
42
|
// transform-input.ts (imported by the pipeline) calls processInput; return
|
|
22
43
|
// the raw shape as an InputParam so filtering/mapping runs end-to-end.
|
|
23
|
-
processInput: (raw: InputParam) => raw
|
|
44
|
+
processInput: (raw: InputParam) => raw,
|
|
45
|
+
// The L2 key module hashes over stableStringify — a deterministic sorted-key
|
|
46
|
+
// JSON is enough for the tests (real impl sorts recursively).
|
|
47
|
+
stableStringify: (value: unknown) => JSON.stringify(value),
|
|
48
|
+
RhinoComputeError,
|
|
49
|
+
ErrorCodes: { QUEUE_FULL: 'QUEUE_FULL', QUEUE_TIMEOUT: 'QUEUE_TIMEOUT' }
|
|
24
50
|
};
|
|
25
51
|
});
|
|
26
52
|
|
|
53
|
+
// The mocked class, typed loosely for constructing test errors. The `as` cast
|
|
54
|
+
// keeps the 3-arg (message, code, options) signature the pipeline's mock uses.
|
|
55
|
+
import { RhinoComputeError as MockRhinoComputeError } from '@selvajs/compute';
|
|
56
|
+
const makeShedError = (
|
|
57
|
+
message: string,
|
|
58
|
+
code: 'QUEUE_FULL' | 'QUEUE_TIMEOUT' | 'UNKNOWN_ERROR',
|
|
59
|
+
options?: { statusCode?: number; context?: Record<string, unknown> }
|
|
60
|
+
) =>
|
|
61
|
+
new (MockRhinoComputeError as unknown as new (
|
|
62
|
+
m: string,
|
|
63
|
+
c: string,
|
|
64
|
+
o?: { statusCode?: number; context?: Record<string, unknown> }
|
|
65
|
+
) => Error)(message, code, options);
|
|
66
|
+
|
|
27
67
|
import {
|
|
28
68
|
runSolvePipeline,
|
|
29
69
|
COMPUTE_CONTRACT_VERSION,
|
|
30
70
|
COMPUTE_VERSION_HEADER,
|
|
71
|
+
deriveSolveCacheInputKey,
|
|
72
|
+
encodeSolveCacheEntry,
|
|
31
73
|
type SolvePipelineArgs,
|
|
74
|
+
type SolvePipelineCacheHook,
|
|
32
75
|
type PipelineInput,
|
|
33
76
|
type CachedClient
|
|
34
77
|
} from '../index.js';
|
|
78
|
+
import { gzipSync } from 'node:zlib';
|
|
35
79
|
|
|
36
80
|
/**
|
|
37
81
|
* A scripted solve stub. The pipeline only JSON-serializes and reads
|
|
@@ -49,8 +93,8 @@ function fakeClient(solve: SolveStub): CachedClient {
|
|
|
49
93
|
return {
|
|
50
94
|
client: {} as GrasshopperClient,
|
|
51
95
|
scheduler: { solve } as unknown as SolveScheduler,
|
|
52
|
-
rhinoTiming: { last: null },
|
|
53
|
-
solveMeta: { last: null }
|
|
96
|
+
rhinoTiming: { last: null, seq: 0 },
|
|
97
|
+
solveMeta: { last: null, seq: 0 }
|
|
54
98
|
};
|
|
55
99
|
}
|
|
56
100
|
|
|
@@ -196,6 +240,33 @@ describe('runSolvePipeline — failure outcomes', () => {
|
|
|
196
240
|
expect(outcome.kind).toBe('client_abort');
|
|
197
241
|
});
|
|
198
242
|
|
|
243
|
+
it('R3: a client disconnect while QUEUED forwards the signal and settles client_abort without a result', async () => {
|
|
244
|
+
// Models the queued-disconnect path the package fix (ISSUES 46) closes: the
|
|
245
|
+
// signal fires while the item waits in the FIFO, the scheduler settles it
|
|
246
|
+
// ABORTED (rejects with AbortError) and never runs compute. The pipeline's
|
|
247
|
+
// job — verified here — is to (a) hand the request signal to scheduler.solve
|
|
248
|
+
// so that prune can happen, and (b) classify the aborted-signal reject as
|
|
249
|
+
// client_abort (→ 499), never leaking a compute result.
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
let seenSignal: AbortSignal | undefined;
|
|
252
|
+
const outcome = await runSolvePipeline(
|
|
253
|
+
baseArgs({
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
client: fakeClient(async (_d, _t, opts) => {
|
|
256
|
+
seenSignal = opts.signal;
|
|
257
|
+
// The disconnect arrives while queued: abort, then settle ABORTED —
|
|
258
|
+
// compute output is never produced (the loader throws, never returns).
|
|
259
|
+
controller.abort();
|
|
260
|
+
const e = new Error('aborted while queued');
|
|
261
|
+
e.name = 'AbortError';
|
|
262
|
+
throw e;
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
expect(seenSignal).toBe(controller.signal); // scheduler CAN observe the disconnect
|
|
267
|
+
expect(outcome.kind).toBe('client_abort'); // → 499, not a 500/compute_error
|
|
268
|
+
});
|
|
269
|
+
|
|
199
270
|
it('classifies any other throw as compute_error carrying the original error', async () => {
|
|
200
271
|
const boom = new TypeError('fetch failed');
|
|
201
272
|
const outcome = await runSolvePipeline(
|
|
@@ -219,6 +290,57 @@ describe('runSolvePipeline — failure outcomes', () => {
|
|
|
219
290
|
);
|
|
220
291
|
expect(outcome.kind).toBe('too_large');
|
|
221
292
|
});
|
|
293
|
+
|
|
294
|
+
it('classifies a QUEUE_FULL rejection as shed(queue_full) with a Retry-After hint', async () => {
|
|
295
|
+
const outcome = await runSolvePipeline(
|
|
296
|
+
baseArgs({
|
|
297
|
+
client: fakeClient(async () => {
|
|
298
|
+
throw makeShedError('queue full', 'QUEUE_FULL', {
|
|
299
|
+
statusCode: 503,
|
|
300
|
+
context: { queueDepth: 8, maxQueueDepth: 8 }
|
|
301
|
+
});
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
expect(outcome.kind).toBe('shed');
|
|
306
|
+
if (outcome.kind !== 'shed') return;
|
|
307
|
+
expect(outcome.reason).toBe('queue_full');
|
|
308
|
+
expect(outcome.retryAfterSeconds).toBeGreaterThan(0);
|
|
309
|
+
expect(outcome.message).toBeTruthy();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('classifies a QUEUE_TIMEOUT rejection as shed(queue_timeout)', async () => {
|
|
313
|
+
const outcome = await runSolvePipeline(
|
|
314
|
+
baseArgs({
|
|
315
|
+
client: fakeClient(async () => {
|
|
316
|
+
throw makeShedError('queued too long', 'QUEUE_TIMEOUT', {
|
|
317
|
+
statusCode: 503,
|
|
318
|
+
context: { waitedMs: 5000, queueWaitMs: 3000 }
|
|
319
|
+
});
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
expect(outcome.kind).toBe('shed');
|
|
324
|
+
if (outcome.kind !== 'shed') return;
|
|
325
|
+
expect(outcome.reason).toBe('queue_timeout');
|
|
326
|
+
expect(outcome.retryAfterSeconds).toBeGreaterThan(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('does NOT shed a RhinoComputeError with an unrelated code (stays compute_error)', async () => {
|
|
330
|
+
const boom = makeShedError('server exploded', 'UNKNOWN_ERROR', {
|
|
331
|
+
statusCode: 500
|
|
332
|
+
});
|
|
333
|
+
const outcome = await runSolvePipeline(
|
|
334
|
+
baseArgs({
|
|
335
|
+
client: fakeClient(async () => {
|
|
336
|
+
throw boom;
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
expect(outcome.kind).toBe('compute_error');
|
|
341
|
+
if (outcome.kind !== 'compute_error') return;
|
|
342
|
+
expect(outcome.error).toBe(boom);
|
|
343
|
+
});
|
|
222
344
|
});
|
|
223
345
|
|
|
224
346
|
describe('runSolvePipeline — Server-Timing', () => {
|
|
@@ -246,11 +368,15 @@ describe('runSolvePipeline — Server-Timing', () => {
|
|
|
246
368
|
});
|
|
247
369
|
|
|
248
370
|
it('splits solve into rhino_* + compute_link when the client reported server timing', async () => {
|
|
249
|
-
// The real
|
|
250
|
-
//
|
|
251
|
-
//
|
|
371
|
+
// The real callbacks fire DURING the solve: onServerTiming per compute
|
|
372
|
+
// call, onSettle per settle — each bumps its seq. Mimic both so the
|
|
373
|
+
// pipeline's attribution guard (exactly one write since the snapshot,
|
|
374
|
+
// settle not fromCache) sees this request's own telemetry.
|
|
252
375
|
const client: CachedClient = fakeClient(async () => {
|
|
376
|
+
client.rhinoTiming.seq += 1;
|
|
253
377
|
client.rhinoTiming.last = { decode: 5, solve: 20, encode: 3 };
|
|
378
|
+
client.solveMeta.seq += 1;
|
|
379
|
+
client.solveMeta.last = { fromCache: false, definitionReuploaded: false };
|
|
254
380
|
return { errors: [], warnings: [] };
|
|
255
381
|
});
|
|
256
382
|
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
@@ -264,9 +390,8 @@ describe('runSolvePipeline — Server-Timing', () => {
|
|
|
264
390
|
});
|
|
265
391
|
|
|
266
392
|
it('surfaces selva_cache + def_reupload verdicts as 0/1 flags', async () => {
|
|
267
|
-
// Populate during the solve (the onSettle callback's real timing), not
|
|
268
|
-
// before — the pipeline resets solveMeta before it runs.
|
|
269
393
|
const client: CachedClient = fakeClient(async () => {
|
|
394
|
+
client.solveMeta.seq += 1;
|
|
270
395
|
client.solveMeta.last = { fromCache: true, definitionReuploaded: false };
|
|
271
396
|
return { errors: [], warnings: [] };
|
|
272
397
|
});
|
|
@@ -278,17 +403,258 @@ describe('runSolvePipeline — Server-Timing', () => {
|
|
|
278
403
|
expect(st).toContain('def_reupload;dur=0');
|
|
279
404
|
});
|
|
280
405
|
|
|
281
|
-
it('
|
|
282
|
-
//
|
|
283
|
-
//
|
|
406
|
+
it('does not attribute stale telemetry a previous request left on the warm client', async () => {
|
|
407
|
+
// The holders are shared per-server. Data written before this solve's
|
|
408
|
+
// snapshot (seq unchanged during the solve) must not leak into this
|
|
409
|
+
// request's Server-Timing.
|
|
284
410
|
const client = fakeClient(async () => ({ errors: [], warnings: [] }));
|
|
285
|
-
client.rhinoTiming
|
|
286
|
-
client.solveMeta
|
|
411
|
+
client.rhinoTiming = { last: { decode: 99, solve: 99, encode: 99 }, seq: 3 };
|
|
412
|
+
client.solveMeta = { last: { fromCache: true }, seq: 5 };
|
|
287
413
|
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
288
414
|
expect(outcome.kind).toBe('ok');
|
|
289
415
|
if (outcome.kind !== 'ok') return;
|
|
290
|
-
// Holders were reset to null before solve; the stub never repopulated them.
|
|
291
416
|
expect(outcome.envelope.headers['Server-Timing']).not.toContain('rhino_decode');
|
|
292
417
|
expect(outcome.envelope.headers['Server-Timing']).not.toContain('selva_cache');
|
|
293
418
|
});
|
|
419
|
+
|
|
420
|
+
it('drops telemetry when a concurrent request also settled in the window (audit B6)', async () => {
|
|
421
|
+
// With maxConcurrentSolves > 1 two solves can interleave. More than one
|
|
422
|
+
// settle since this request's snapshot makes the shared slots ambiguous —
|
|
423
|
+
// the pipeline must omit the segments rather than report another
|
|
424
|
+
// request's cache verdict / rhino timing as ours.
|
|
425
|
+
const client: CachedClient = fakeClient(async () => {
|
|
426
|
+
// Our own solve's callbacks…
|
|
427
|
+
client.rhinoTiming.seq += 1;
|
|
428
|
+
client.rhinoTiming.last = { decode: 5, solve: 20, encode: 3 };
|
|
429
|
+
client.solveMeta.seq += 1;
|
|
430
|
+
client.solveMeta.last = { fromCache: false, definitionReuploaded: false };
|
|
431
|
+
// …then a concurrent request settles before we read.
|
|
432
|
+
client.rhinoTiming.seq += 1;
|
|
433
|
+
client.rhinoTiming.last = { decode: 1, solve: 1, encode: 1 };
|
|
434
|
+
client.solveMeta.seq += 1;
|
|
435
|
+
client.solveMeta.last = { fromCache: true };
|
|
436
|
+
return { errors: [], warnings: [] };
|
|
437
|
+
});
|
|
438
|
+
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
439
|
+
expect(outcome.kind).toBe('ok');
|
|
440
|
+
if (outcome.kind !== 'ok') return;
|
|
441
|
+
const st = outcome.envelope.headers['Server-Timing'];
|
|
442
|
+
expect(st).not.toContain('selva_cache');
|
|
443
|
+
expect(st).not.toContain('rhino_decode');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('emits def_bytes=skipped when the byteRefOutcome shows load() never ran (pointer solve)', async () => {
|
|
447
|
+
const outcome = await runSolvePipeline(
|
|
448
|
+
baseArgs({
|
|
449
|
+
byteRefOutcome: { loaded: false, fromCache: false },
|
|
450
|
+
client: fakeClient(async () => ({ errors: [], warnings: [] }))
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
expect(outcome.kind).toBe('ok');
|
|
454
|
+
if (outcome.kind !== 'ok') return;
|
|
455
|
+
expect(outcome.envelope.headers['Server-Timing']).toContain('def_bytes;desc=skipped');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('emits def_bytes=hit when bytes were served from the warm cache', async () => {
|
|
459
|
+
const outcome = await runSolvePipeline(
|
|
460
|
+
baseArgs({
|
|
461
|
+
byteRefOutcome: { loaded: true, fromCache: true },
|
|
462
|
+
client: fakeClient(async () => ({ errors: [], warnings: [] }))
|
|
463
|
+
})
|
|
464
|
+
);
|
|
465
|
+
expect(outcome.kind).toBe('ok');
|
|
466
|
+
if (outcome.kind !== 'ok') return;
|
|
467
|
+
expect(outcome.envelope.headers['Server-Timing']).toContain('def_bytes;desc=hit');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('emits def_bytes=miss when the loader was called', async () => {
|
|
471
|
+
const outcome = await runSolvePipeline(
|
|
472
|
+
baseArgs({
|
|
473
|
+
byteRefOutcome: { loaded: true, fromCache: false },
|
|
474
|
+
client: fakeClient(async () => ({ errors: [], warnings: [] }))
|
|
475
|
+
})
|
|
476
|
+
);
|
|
477
|
+
expect(outcome.kind).toBe('ok');
|
|
478
|
+
if (outcome.kind !== 'ok') return;
|
|
479
|
+
expect(outcome.envelope.headers['Server-Timing']).toContain('def_bytes;desc=miss');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('omits def_bytes entirely for a raw-bytes solve (no byteRefOutcome)', async () => {
|
|
483
|
+
const outcome = await runSolvePipeline(
|
|
484
|
+
baseArgs({ client: fakeClient(async () => ({ errors: [], warnings: [] })) })
|
|
485
|
+
);
|
|
486
|
+
expect(outcome.kind).toBe('ok');
|
|
487
|
+
if (outcome.kind !== 'ok') return;
|
|
488
|
+
expect(outcome.envelope.headers['Server-Timing']).not.toContain('def_bytes');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('never attributes rhino timing to a Selva-cache hit (no compute call of ours)', async () => {
|
|
492
|
+
// Our request was served from the in-process cache while a concurrent
|
|
493
|
+
// request's compute call produced the only rhino timing in the window.
|
|
494
|
+
const client: CachedClient = fakeClient(async () => {
|
|
495
|
+
client.rhinoTiming.seq += 1; // the OTHER request's compute call
|
|
496
|
+
client.rhinoTiming.last = { decode: 7, solve: 70, encode: 7 };
|
|
497
|
+
client.solveMeta.seq += 1; // our own settle — a cache hit
|
|
498
|
+
client.solveMeta.last = { fromCache: true };
|
|
499
|
+
return { errors: [], warnings: [] };
|
|
500
|
+
});
|
|
501
|
+
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
502
|
+
expect(outcome.kind).toBe('ok');
|
|
503
|
+
if (outcome.kind !== 'ok') return;
|
|
504
|
+
const st = outcome.envelope.headers['Server-Timing'];
|
|
505
|
+
expect(st).toContain('selva_cache;dur=1');
|
|
506
|
+
expect(st).not.toContain('rhino_decode');
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ============================================================================
|
|
511
|
+
// L2 durable solve cache hook (Phase 3.4)
|
|
512
|
+
// ============================================================================
|
|
513
|
+
|
|
514
|
+
describe('runSolvePipeline — L2 solve cache', () => {
|
|
515
|
+
const config = { computeServerId: 'srv-1' };
|
|
516
|
+
|
|
517
|
+
/** A hook backed by a Map, recording lookups/stores for assertions. */
|
|
518
|
+
function fakeHook(seed?: Map<string, Uint8Array>) {
|
|
519
|
+
const store = seed ?? new Map<string, Uint8Array>();
|
|
520
|
+
const lookups: string[] = [];
|
|
521
|
+
const stores: string[] = [];
|
|
522
|
+
const hook: SolvePipelineCacheHook = {
|
|
523
|
+
configSubset: config,
|
|
524
|
+
lookup: async (key) => {
|
|
525
|
+
lookups.push(key);
|
|
526
|
+
return store.get(key) ?? null;
|
|
527
|
+
},
|
|
528
|
+
store: (key, entry) => {
|
|
529
|
+
stores.push(key);
|
|
530
|
+
store.set(key, entry);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
return { hook, store, lookups, stores };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
it('a miss solves, then write-throughs the gzipped envelope and marks l2_cache;dur=0', async () => {
|
|
537
|
+
const result = { values: [{ id: 'out' }], errors: [], warnings: ['w'] };
|
|
538
|
+
let solves = 0;
|
|
539
|
+
const { hook, store, stores } = fakeHook();
|
|
540
|
+
const outcome = await runSolvePipeline(
|
|
541
|
+
baseArgs({
|
|
542
|
+
client: fakeClient(async () => {
|
|
543
|
+
solves += 1;
|
|
544
|
+
return result;
|
|
545
|
+
}),
|
|
546
|
+
acceptEncoding: 'gzip',
|
|
547
|
+
solveCache: hook
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
expect(outcome.kind).toBe('ok');
|
|
551
|
+
if (outcome.kind !== 'ok') return;
|
|
552
|
+
expect(solves).toBe(1);
|
|
553
|
+
expect(outcome.envelope.headers['Server-Timing']).toContain('l2_cache;dur=0');
|
|
554
|
+
// Write-through happened — one entry stored under the derived key.
|
|
555
|
+
expect(stores).toHaveLength(1);
|
|
556
|
+
expect(store.size).toBe(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('a hit returns the stored envelope WITHOUT solving and marks l2_cache;dur=1', async () => {
|
|
560
|
+
const result = { values: [{ id: 'cached' }], errors: [], warnings: [] };
|
|
561
|
+
const serialized = JSON.stringify(result);
|
|
562
|
+
// Pre-seed the store at the key the pipeline will derive for these inputs.
|
|
563
|
+
const args = baseArgs({ acceptEncoding: 'gzip' });
|
|
564
|
+
const tree = { __tree: true, count: 1 }; // matches the mock TreeBuilder output
|
|
565
|
+
const inputKey = deriveSolveCacheInputKey(tree, config).hash;
|
|
566
|
+
const entry = encodeSolveCacheEntry(
|
|
567
|
+
{ errorCount: 0, warningCount: 0, serializedBytes: serialized.length, inputHash: inputKey },
|
|
568
|
+
new Uint8Array(gzipSync(Buffer.from(serialized)))
|
|
569
|
+
);
|
|
570
|
+
const seed = new Map([[inputKey, entry]]);
|
|
571
|
+
const { hook } = fakeHook(seed);
|
|
572
|
+
|
|
573
|
+
let solves = 0;
|
|
574
|
+
const outcome = await runSolvePipeline({
|
|
575
|
+
...args,
|
|
576
|
+
client: fakeClient(async () => {
|
|
577
|
+
solves += 1;
|
|
578
|
+
return { values: [], errors: [], warnings: [] };
|
|
579
|
+
}),
|
|
580
|
+
solveCache: hook
|
|
581
|
+
});
|
|
582
|
+
expect(outcome.kind).toBe('ok');
|
|
583
|
+
if (outcome.kind !== 'ok') return;
|
|
584
|
+
expect(solves).toBe(0); // never solved — served from L2
|
|
585
|
+
expect(outcome.envelope.encoding).toBe('gzip');
|
|
586
|
+
expect(gunzipSync(outcome.envelope.body as Uint8Array).toString()).toBe(serialized);
|
|
587
|
+
expect(outcome.envelope.headers['Server-Timing']).toContain('l2_cache;dur=1');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('a hit for a non-gzip client gunzips the stored body back to JSON', async () => {
|
|
591
|
+
const result = { values: [{ id: 'x' }], errors: [], warnings: [] };
|
|
592
|
+
const serialized = JSON.stringify(result);
|
|
593
|
+
const tree = { __tree: true, count: 1 };
|
|
594
|
+
const inputKey = deriveSolveCacheInputKey(tree, config).hash;
|
|
595
|
+
const entry = encodeSolveCacheEntry(
|
|
596
|
+
{ errorCount: 0, warningCount: 0, serializedBytes: serialized.length, inputHash: inputKey },
|
|
597
|
+
new Uint8Array(gzipSync(Buffer.from(serialized)))
|
|
598
|
+
);
|
|
599
|
+
const { hook } = fakeHook(new Map([[inputKey, entry]]));
|
|
600
|
+
const outcome = await runSolvePipeline(baseArgs({ acceptEncoding: '', solveCache: hook }));
|
|
601
|
+
expect(outcome.kind).toBe('ok');
|
|
602
|
+
if (outcome.kind !== 'ok') return;
|
|
603
|
+
expect(outcome.envelope.encoding).toBeUndefined();
|
|
604
|
+
expect(outcome.envelope.body).toBe(serialized);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('a stored entry whose inputHash mismatches is ignored (collision defense) and re-solves', async () => {
|
|
608
|
+
// Seed the derived key with an entry carrying a DIFFERENT inputHash — a
|
|
609
|
+
// (hypothetical) storage-key collision must not serve the wrong geometry.
|
|
610
|
+
const tree = { __tree: true, count: 1 };
|
|
611
|
+
const inputKey = deriveSolveCacheInputKey(tree, config).hash;
|
|
612
|
+
const wrong = encodeSolveCacheEntry(
|
|
613
|
+
{ errorCount: 0, warningCount: 0, serializedBytes: 2, inputHash: 'not-the-real-hash' },
|
|
614
|
+
new Uint8Array(gzipSync(Buffer.from('{}')))
|
|
615
|
+
);
|
|
616
|
+
const { hook } = fakeHook(new Map([[inputKey, wrong]]));
|
|
617
|
+
let solves = 0;
|
|
618
|
+
const outcome = await runSolvePipeline(
|
|
619
|
+
baseArgs({
|
|
620
|
+
client: fakeClient(async () => {
|
|
621
|
+
solves += 1;
|
|
622
|
+
return { values: [{ id: 'fresh' }], errors: [], warnings: [] };
|
|
623
|
+
}),
|
|
624
|
+
solveCache: hook
|
|
625
|
+
})
|
|
626
|
+
);
|
|
627
|
+
expect(outcome.kind).toBe('ok');
|
|
628
|
+
if (outcome.kind !== 'ok') return;
|
|
629
|
+
expect(solves).toBe(1); // fell through to a real solve
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('omits l2_cache from Server-Timing when no hook is passed', async () => {
|
|
633
|
+
const outcome = await runSolvePipeline(baseArgs());
|
|
634
|
+
expect(outcome.kind).toBe('ok');
|
|
635
|
+
if (outcome.kind !== 'ok') return;
|
|
636
|
+
expect(outcome.envelope.headers['Server-Timing']).not.toContain('l2_cache');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('a lookup that rejects is treated as a miss (best-effort), then solves', async () => {
|
|
640
|
+
let solves = 0;
|
|
641
|
+
const hook: SolvePipelineCacheHook = {
|
|
642
|
+
configSubset: config,
|
|
643
|
+
lookup: async () => {
|
|
644
|
+
throw new Error('backend down');
|
|
645
|
+
},
|
|
646
|
+
store: () => {}
|
|
647
|
+
};
|
|
648
|
+
const outcome = await runSolvePipeline(
|
|
649
|
+
baseArgs({
|
|
650
|
+
client: fakeClient(async () => {
|
|
651
|
+
solves += 1;
|
|
652
|
+
return { values: [], errors: [], warnings: [] };
|
|
653
|
+
}),
|
|
654
|
+
solveCache: hook
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
expect(outcome.kind).toBe('ok');
|
|
658
|
+
expect(solves).toBe(1);
|
|
659
|
+
});
|
|
294
660
|
});
|
|
@@ -59,11 +59,18 @@ describe('transformInputParameter — number / integer', () => {
|
|
|
59
59
|
expect(out.default).toBe(3);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('
|
|
62
|
+
it('honors a schema-authored stepSize verbatim for integers (compute ≥3.1.0-beta.5)', () => {
|
|
63
|
+
// The package used to force integer stepSize to 1; since beta.5 a
|
|
64
|
+
// server-authored stepSize wins and the 1-heuristic is only the fallback.
|
|
63
65
|
const out = transformInputParameter(
|
|
64
66
|
input({ paramType: 'integer', stepSize: 0.5, default: 2 }),
|
|
65
67
|
4
|
|
66
68
|
);
|
|
69
|
+
expect(out).toMatchObject({ paramType: 'Integer', stepSize: 0.5, default: 4 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('falls back to stepSize 1 for integers without an authored stepSize', () => {
|
|
73
|
+
const out = transformInputParameter(input({ paramType: 'integer', default: 2 }), 4);
|
|
67
74
|
expect(out).toMatchObject({ paramType: 'Integer', stepSize: 1, default: 4 });
|
|
68
75
|
});
|
|
69
76
|
});
|
|
@@ -59,21 +59,28 @@ export interface CachedClient {
|
|
|
59
59
|
client: GrasshopperClient;
|
|
60
60
|
scheduler: SolveScheduler;
|
|
61
61
|
/**
|
|
62
|
-
* Last decode/solve/encode reported by the compute server (Server-Timing)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* Last decode/solve/encode reported by the compute server (Server-Timing),
|
|
63
|
+
* written by onServerTiming. `seq` increments on every write. The scheduler
|
|
64
|
+
* runs up to `maxConcurrentSolves` solves at once, so `last` alone can't be
|
|
65
|
+
* trusted per-request: callers snapshot `seq` before their solve and
|
|
66
|
+
* attribute `last` only when exactly one write happened since (necessarily
|
|
67
|
+
* theirs — see the guard in `runSolvePipeline`), dropping the segment
|
|
68
|
+
* otherwise instead of misattributing another request's timing.
|
|
68
69
|
*/
|
|
69
|
-
rhinoTiming: { last: { decode: number; solve: number; encode: number } | null };
|
|
70
|
+
rhinoTiming: { last: { decode: number; solve: number; encode: number } | null; seq: number };
|
|
70
71
|
/**
|
|
71
72
|
* Last solve's cache verdicts from the scheduler's onSettle: whether the
|
|
72
73
|
* in-process response cache served it (no compute call) and whether the
|
|
73
|
-
* definition had to be re-uploaded.
|
|
74
|
-
*
|
|
74
|
+
* definition had to be re-uploaded. `seq` increments on EVERY settle
|
|
75
|
+
* (success or error); `last` is only written on success. Same
|
|
76
|
+
* snapshot-and-attribute pattern as rhinoTiming — onSettle always fires
|
|
77
|
+
* before the corresponding `scheduler.solve()` promise resolves, so at the
|
|
78
|
+
* caller's read point its own settle is included in `seq`.
|
|
75
79
|
*/
|
|
76
|
-
solveMeta: {
|
|
80
|
+
solveMeta: {
|
|
81
|
+
last: { fromCache: boolean; definitionReuploaded?: boolean } | null;
|
|
82
|
+
seq: number;
|
|
83
|
+
};
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
/**
|
|
@@ -90,6 +97,24 @@ export interface ClientCacheConfig {
|
|
|
90
97
|
cacheerroredsolves: boolean;
|
|
91
98
|
/** Reference large definitions by server cache key (pointer) instead of re-uploading. */
|
|
92
99
|
reuseServerDefinitionCache: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Max in-flight solves per compute server (scheduler `maxConcurrent`; excess
|
|
102
|
+
* FIFO-queues). Size to the server's `compute.geometry` child count
|
|
103
|
+
* (`ComputeLimits.computeMaxConcurrentSolves`).
|
|
104
|
+
*/
|
|
105
|
+
maxConcurrentSolves: number;
|
|
106
|
+
/**
|
|
107
|
+
* Backpressure — max solves that may WAIT in the FIFO queue (excludes the
|
|
108
|
+
* in-flight `maxConcurrentSolves`). `0` = unbounded (`ComputeLimits.
|
|
109
|
+
* computeMaxQueueDepth`); a full queue sheds new solves with `QUEUE_FULL`.
|
|
110
|
+
*/
|
|
111
|
+
maxQueueDepth: number;
|
|
112
|
+
/**
|
|
113
|
+
* Backpressure — max ms a solve may sit queued before executing; `0` = no
|
|
114
|
+
* deadline (`ComputeLimits.computeQueueWaitMs`). A too-long wait sheds with
|
|
115
|
+
* `QUEUE_TIMEOUT`.
|
|
116
|
+
*/
|
|
117
|
+
queueWaitMs: number;
|
|
93
118
|
/** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
|
|
94
119
|
debug: boolean;
|
|
95
120
|
/** VERBOSE lib-level logging (full solve request/response incl. geometry). */
|
|
@@ -136,9 +161,10 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
136
161
|
|
|
137
162
|
async function build(server: ResolvedServer, definitionGuid?: string): Promise<CachedClient> {
|
|
138
163
|
// Holders for per-request results from the client/scheduler callbacks; the
|
|
139
|
-
// caller reads them right after each solve resolves to build Server-Timing
|
|
140
|
-
|
|
141
|
-
const
|
|
164
|
+
// caller reads them right after each solve resolves to build Server-Timing,
|
|
165
|
+
// using the seq counters to detect (and drop) ambiguous concurrent writes.
|
|
166
|
+
const rhinoTiming: CachedClient['rhinoTiming'] = { last: null, seq: 0 };
|
|
167
|
+
const solveMeta: CachedClient['solveMeta'] = { last: null, seq: 0 };
|
|
142
168
|
|
|
143
169
|
const clientConfig: GrasshopperComputeConfig = {
|
|
144
170
|
serverUrl: server.serverUrl,
|
|
@@ -160,6 +186,7 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
160
186
|
const solve = t.solve ?? 0;
|
|
161
187
|
const encode = t.encode ?? 0;
|
|
162
188
|
// Always recorded (cheap): the caller reads this after scheduler.solve.
|
|
189
|
+
rhinoTiming.seq += 1;
|
|
163
190
|
rhinoTiming.last = { decode, solve, encode };
|
|
164
191
|
if (!config.debug) return;
|
|
165
192
|
// Heuristic thresholds: a genuine parse/solve is many ms; a cache hit
|
|
@@ -189,6 +216,15 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
189
216
|
const client = await GrasshopperClient.create(clientConfig);
|
|
190
217
|
const scheduler = client.createScheduler({
|
|
191
218
|
mode: 'queue',
|
|
219
|
+
// Queue mode defaults maxConcurrent to 1, which would serialize every
|
|
220
|
+
// solve on this server through a single slot while the compute VM's
|
|
221
|
+
// other compute.geometry children idle (audit B6) — always pass it.
|
|
222
|
+
maxConcurrent: config.maxConcurrentSolves,
|
|
223
|
+
// Backpressure (audit B7). The scheduler treats undefined as
|
|
224
|
+
// unbounded/no-deadline, so map our `0`-means-off convention to undefined
|
|
225
|
+
// rather than passing 0 (which would shed EVERY queued solve).
|
|
226
|
+
maxQueueDepth: config.maxQueueDepth || undefined,
|
|
227
|
+
queueWaitMs: config.queueWaitMs || undefined,
|
|
192
228
|
timeoutMs: config.maxSolveDurationMs,
|
|
193
229
|
cache: { maxEntries: 20, ttlMs: 5 * 60_000 },
|
|
194
230
|
// Solve large definitions by server cache-key (pointer) instead of
|
|
@@ -199,6 +235,9 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
199
235
|
// this solve was served from the app's own cache — Rhino.Compute was never
|
|
200
236
|
// called. `false` means it went to the compute server.
|
|
201
237
|
onSettle: (_ctx, result) => {
|
|
238
|
+
// Count EVERY settle (success or error) so the caller's attribution
|
|
239
|
+
// guard sees all concurrent activity, not just successful writes.
|
|
240
|
+
solveMeta.seq += 1;
|
|
202
241
|
if (result.status !== 'success') return;
|
|
203
242
|
// Always recorded (cheap): the caller surfaces these on Server-Timing.
|
|
204
243
|
solveMeta.last = {
|
|
@@ -219,14 +258,18 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
219
258
|
: '[Compute/def-cache] n/a — reuse disabled or non-reusable definition'
|
|
220
259
|
);
|
|
221
260
|
}
|
|
222
|
-
// Surface real GH errors when present.
|
|
223
|
-
//
|
|
261
|
+
// Surface real GH errors when present. Whether an errored solve is cached is
|
|
262
|
+
// config-gated by `cacheerroredsolves` (default false → errored solves are
|
|
263
|
+
// re-run each time; true → the error result is cached like any other, per R2).
|
|
224
264
|
if (!result.fromCache) {
|
|
225
265
|
const errs = (result.response as { errors?: unknown[] })?.errors;
|
|
226
266
|
const errCount = Array.isArray(errs) ? errs.length : 0;
|
|
227
267
|
if (errCount > 0) {
|
|
268
|
+
const cacheNote = config.cacheerroredsolves
|
|
269
|
+
? 'cached (cacheerroredsolves on)'
|
|
270
|
+
: 'not cached (cacheerroredsolves off) — re-run until fixed';
|
|
228
271
|
debugLog(
|
|
229
|
-
`[Compute/rhino-cache] ${errCount} GH error(s);
|
|
272
|
+
`[Compute/rhino-cache] ${errCount} GH error(s); ${cacheNote}. First: ${JSON.stringify((errs as unknown[])[0])}`
|
|
230
273
|
);
|
|
231
274
|
}
|
|
232
275
|
}
|