@selvajs/server 0.2.0-beta.2 → 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/compute/__tests__/client-cache.test.js +18 -0
- 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__/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 +267 -2
- package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
- package/dist/compute/client-cache.d.ts +12 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +12 -3
- 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 +52 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +33 -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/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 +178 -8
- package/dist/compute/solve-pipeline.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/package.json +2 -2
- package/src/compute/__tests__/client-cache.test.ts +20 -0
- package/src/compute/__tests__/definition-byte-cache.test.ts +129 -0
- package/src/compute/__tests__/solve-cache.test.ts +268 -0
- package/src/compute/__tests__/solve-pipeline.test.ts +320 -1
- package/src/compute/client-cache.ts +24 -3
- package/src/compute/definition-byte-cache.ts +168 -0
- package/src/compute/index.ts +34 -0
- package/src/compute/limits.ts +79 -1
- package/src/compute/memory-solve-cache.ts +0 -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 +279 -15
- package/src/definitions/__tests__/definition-service.test.ts +131 -0
- package/src/definitions/definition-service.ts +8 -4
|
@@ -20,11 +20,29 @@
|
|
|
20
20
|
* see {@link COMPUTE_CONTRACT_VERSION} and {@link COMPUTE_VERSION_HEADER}.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
ErrorCodes,
|
|
25
|
+
RhinoComputeError,
|
|
26
|
+
TreeBuilder,
|
|
27
|
+
type GrasshopperComputeResponse,
|
|
28
|
+
type InputParam,
|
|
29
|
+
type SolveDefinition
|
|
30
|
+
} from '@selvajs/compute';
|
|
24
31
|
import type { SchemaInput } from '@selvajs/schemas';
|
|
25
|
-
import {
|
|
32
|
+
import { gzip } from 'node:zlib';
|
|
33
|
+
import { promisify } from 'node:util';
|
|
26
34
|
import { transformInputParameter } from './transform-input.js';
|
|
27
35
|
import type { CachedClient } from './client-cache.js';
|
|
36
|
+
import type { ByteRefOutcome } from './definition-byte-cache.js';
|
|
37
|
+
import { deriveSolveCacheInputKey, type SolveCacheConfigSubset } from './solve-cache-key.js';
|
|
38
|
+
import {
|
|
39
|
+
decodeSolveCacheEntry,
|
|
40
|
+
encodeSolveCacheEntry,
|
|
41
|
+
gunzipEntryBody
|
|
42
|
+
} from './solve-cache-envelope.js';
|
|
43
|
+
|
|
44
|
+
/** Async gzip (audit B8) — off the event loop, unlike the old `gzipSync`. */
|
|
45
|
+
const gzipAsync = promisify(gzip);
|
|
28
46
|
|
|
29
47
|
// ============================================================================
|
|
30
48
|
// Wire contract version (audit D5)
|
|
@@ -53,8 +71,20 @@ export type PipelineInput = SchemaInput & {
|
|
|
53
71
|
};
|
|
54
72
|
|
|
55
73
|
export interface SolvePipelineArgs {
|
|
56
|
-
/**
|
|
57
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The definition to solve. Either raw `.gh` bytes (remote fetch, or a caller
|
|
76
|
+
* that resolved them eagerly) or a `DefinitionRef` — an identity-keyed lazy
|
|
77
|
+
* loader (from the definition-byte cache) whose bytes the scheduler
|
|
78
|
+
* materializes ONLY when an upload is unavoidable. A pointer-known solve of a
|
|
79
|
+
* `DefinitionRef` moves zero bytes.
|
|
80
|
+
*/
|
|
81
|
+
definitionSource: SolveDefinition;
|
|
82
|
+
/**
|
|
83
|
+
* When `definitionSource` is a byte-cache `DefinitionRef`, its mutable outcome
|
|
84
|
+
* so the pipeline can emit the `def_bytes` Server-Timing verdict (skipped =
|
|
85
|
+
* `load` never ran, hit = warm cache, miss = loader). Omit for raw-bytes solves.
|
|
86
|
+
*/
|
|
87
|
+
byteRefOutcome?: ByteRefOutcome;
|
|
58
88
|
/** Persisted input params; only those with a `paramType` are sent to the solve. */
|
|
59
89
|
inputs: PipelineInput[];
|
|
60
90
|
/** User-chosen values keyed by input id; missing keys fall back to the schema default. */
|
|
@@ -86,6 +116,31 @@ export interface SolvePipelineArgs {
|
|
|
86
116
|
* Server-Timing entries. The caller assembles these; the pipeline only echoes.
|
|
87
117
|
*/
|
|
88
118
|
prepMarks?: [string, number][];
|
|
119
|
+
/**
|
|
120
|
+
* Optional durable L2 solve cache (H1). Present ONLY for live-channel solves
|
|
121
|
+
* whose definition has a caching quota > 0 — its presence IS the channel gate
|
|
122
|
+
* (zero policy conditionals in the pipeline). When set, the pipeline derives
|
|
123
|
+
* the wide input key from the transformed tree + `configSubset`, checks
|
|
124
|
+
* `lookup` before solving (a hit skips compute entirely, returning the stored
|
|
125
|
+
* gzipped envelope), and write-throughs `store` on a successful solve.
|
|
126
|
+
*/
|
|
127
|
+
solveCache?: SolvePipelineCacheHook;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The app-supplied L2 hook. The app binds `lookup`/`store` to the org/definition/
|
|
132
|
+
* version scope and the definition's quota; the pipeline supplies the SHA-256
|
|
133
|
+
* `inputKey` (derived from the transformed tree + `configSubset`) and the opaque
|
|
134
|
+
* encoded entry bytes. Both are best-effort — `lookup` may miss/throw (treated as
|
|
135
|
+
* a miss), `store` is fire-and-forget.
|
|
136
|
+
*/
|
|
137
|
+
export interface SolvePipelineCacheHook {
|
|
138
|
+
/** Solve-affecting config folded into the key (R8) — model units, tolerances, server id. */
|
|
139
|
+
configSubset: SolveCacheConfigSubset;
|
|
140
|
+
/** Fetch a stored entry for `inputKey`, or `null` on miss. Must not reject fatally. */
|
|
141
|
+
lookup(inputKey: string): Promise<Uint8Array | null>;
|
|
142
|
+
/** Store the encoded entry for `inputKey`. Fire-and-forget; best-effort. */
|
|
143
|
+
store(inputKey: string, entry: Uint8Array): void;
|
|
89
144
|
}
|
|
90
145
|
|
|
91
146
|
/** Phase timings the pipeline measured; the caller uses them for debug logging. */
|
|
@@ -99,6 +154,12 @@ export interface SolvePhaseMetrics {
|
|
|
99
154
|
serializedBytes: number;
|
|
100
155
|
/** Gzipped length in bytes, or null when compression was skipped. */
|
|
101
156
|
compressedBytes: number | null;
|
|
157
|
+
/**
|
|
158
|
+
* L2 durable-cache verdict: `true` = served from L2 (no solve ran), `false` =
|
|
159
|
+
* L2 consulted and missed (solved), `null` = L2 not consulted (no hook — draft
|
|
160
|
+
* or caching off). Drives the `l2_cache` Server-Timing entry.
|
|
161
|
+
*/
|
|
162
|
+
l2CacheHit: boolean | null;
|
|
102
163
|
}
|
|
103
164
|
|
|
104
165
|
/** A ready-to-send response: body + headers + the solve result + phase metrics. */
|
|
@@ -109,8 +170,13 @@ export interface SolveEnvelope {
|
|
|
109
170
|
encoding?: 'gzip';
|
|
110
171
|
/** Fully-assembled response headers (Content-Type/-Length/-Encoding, Vary, Server-Timing, version). */
|
|
111
172
|
headers: Record<string, string>;
|
|
112
|
-
/**
|
|
113
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The solved definition object, present only on a fresh (non-L2) solve. Absent
|
|
175
|
+
* on an L2 hit — the pipeline serves the stored gzipped body without ever
|
|
176
|
+
* materializing the object. The caller reads `errorCount`/`warningCount` off
|
|
177
|
+
* the `ok` outcome for its metric record, not this field.
|
|
178
|
+
*/
|
|
179
|
+
result?: GrasshopperComputeResponse;
|
|
114
180
|
/** Phase timings, for the caller's optional debug breakdown. */
|
|
115
181
|
metrics: SolvePhaseMetrics;
|
|
116
182
|
}
|
|
@@ -118,9 +184,10 @@ export interface SolveEnvelope {
|
|
|
118
184
|
/**
|
|
119
185
|
* Discriminated result. `ok` carries the envelope; every other variant names an
|
|
120
186
|
* expected failure the transport maps to a status code (the app route maps
|
|
121
|
-
* timeout→504, client_abort→499, too_large→413
|
|
122
|
-
* original error for the generic 500/503 path).
|
|
123
|
-
* variants is the solve wall time up to the failure,
|
|
187
|
+
* timeout→504, client_abort→499, too_large→413, shed→503+Retry-After;
|
|
188
|
+
* `compute_error` re-surfaces the original error for the generic 500/503 path).
|
|
189
|
+
* `durationMs` on the error variants is the solve wall time up to the failure,
|
|
190
|
+
* for the metric record.
|
|
124
191
|
*/
|
|
125
192
|
export type SolveOutcome =
|
|
126
193
|
| {
|
|
@@ -133,6 +200,19 @@ export type SolveOutcome =
|
|
|
133
200
|
| { kind: 'timeout'; durationMs: number; message: string }
|
|
134
201
|
| { kind: 'client_abort'; durationMs: number }
|
|
135
202
|
| { kind: 'too_large' }
|
|
203
|
+
/**
|
|
204
|
+
* Scheduler backpressure shed the solve before it executed — the per-server
|
|
205
|
+
* queue was full (`QUEUE_FULL`) or it sat queued past the wait deadline
|
|
206
|
+
* (`QUEUE_TIMEOUT`). Retryable: the route maps this to 503 + `Retry-After`.
|
|
207
|
+
* `retryAfterSeconds` is a suggested backoff hint for the client.
|
|
208
|
+
*/
|
|
209
|
+
| {
|
|
210
|
+
kind: 'shed';
|
|
211
|
+
durationMs: number;
|
|
212
|
+
reason: 'queue_full' | 'queue_timeout';
|
|
213
|
+
retryAfterSeconds: number;
|
|
214
|
+
message: string;
|
|
215
|
+
}
|
|
136
216
|
| { kind: 'compute_error'; durationMs: number; error: unknown };
|
|
137
217
|
|
|
138
218
|
// ============================================================================
|
|
@@ -155,6 +235,28 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
155
235
|
);
|
|
156
236
|
const treeBuildMs = performance.now() - treeBuildStart;
|
|
157
237
|
|
|
238
|
+
// --- L2 durable cache lookup (H1) ---------------------------------------
|
|
239
|
+
// Derive the wide key from the TRANSFORMED tree (R13) + config subset (R8),
|
|
240
|
+
// then check the injected hook. Present only for cacheable live solves — its
|
|
241
|
+
// presence is the channel gate. A hit skips the solve entirely and serves the
|
|
242
|
+
// stored gzipped envelope; the derived key is reused for the write-through on
|
|
243
|
+
// a miss. Lookup is best-effort: any rejection is swallowed to a miss.
|
|
244
|
+
let inputKey: string | null = null;
|
|
245
|
+
if (args.solveCache) {
|
|
246
|
+
inputKey = deriveSolveCacheInputKey(inputTree, args.solveCache.configSubset).hash;
|
|
247
|
+
let stored: Uint8Array | null = null;
|
|
248
|
+
try {
|
|
249
|
+
stored = await args.solveCache.lookup(inputKey);
|
|
250
|
+
} catch {
|
|
251
|
+
stored = null;
|
|
252
|
+
}
|
|
253
|
+
if (stored) {
|
|
254
|
+
const hit = buildEnvelopeFromCacheEntry(stored, inputKey, args);
|
|
255
|
+
if (hit) return hit;
|
|
256
|
+
// Corrupt/tampered entry → fall through and solve as a miss.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
158
260
|
// --- solve --------------------------------------------------------------
|
|
159
261
|
// Snapshot the shared telemetry sequence counters. The holders live on the
|
|
160
262
|
// warm client and are written by every request on this server; with
|
|
@@ -184,6 +286,30 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
184
286
|
message: `Solve exceeded the ${Math.round(args.maxSolveDurationMs / 1000)}s deadline.`
|
|
185
287
|
};
|
|
186
288
|
}
|
|
289
|
+
// Scheduler backpressure (audit B7): a full queue or an over-deadline queue
|
|
290
|
+
// wait sheds the solve BEFORE compute runs. Classify these as `shed` (503 +
|
|
291
|
+
// Retry-After) rather than the generic 500 `compute_error` path — they're
|
|
292
|
+
// load signals, not failures, and the client should back off and retry.
|
|
293
|
+
if (err instanceof RhinoComputeError) {
|
|
294
|
+
if (err.code === ErrorCodes.QUEUE_FULL) {
|
|
295
|
+
return {
|
|
296
|
+
kind: 'shed',
|
|
297
|
+
durationMs,
|
|
298
|
+
reason: 'queue_full',
|
|
299
|
+
retryAfterSeconds: 1,
|
|
300
|
+
message: 'Compute server is at capacity. Retry shortly.'
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (err.code === ErrorCodes.QUEUE_TIMEOUT) {
|
|
304
|
+
return {
|
|
305
|
+
kind: 'shed',
|
|
306
|
+
durationMs,
|
|
307
|
+
reason: 'queue_timeout',
|
|
308
|
+
retryAfterSeconds: 1,
|
|
309
|
+
message: 'Compute request waited too long in the queue. Retry shortly.'
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
187
313
|
return { kind: 'compute_error', durationMs, error: err };
|
|
188
314
|
}
|
|
189
315
|
const solveMs = performance.now() - solveStart;
|
|
@@ -207,14 +333,41 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
207
333
|
// in Server-Timing, included in `total`). Buffered (not streamed) so
|
|
208
334
|
// Content-Length is known and a connection cut mid-transfer fails hard
|
|
209
335
|
// instead of truncating the JSON.
|
|
336
|
+
// gzip is async (audit B8) — a multi-MB `gzipSync` blocked the event loop for
|
|
337
|
+
// every other request on this instance. Two independent reasons to compress:
|
|
338
|
+
// - the client advertised gzip AND the body clears the 1 KB break-even (tiny
|
|
339
|
+
// bodies aren't worth compressing for the wire);
|
|
340
|
+
// - an L2 write-through is pending — the entry stores compressed bytes so
|
|
341
|
+
// every future hit is near-CPU-free, so we compress even a small body (it
|
|
342
|
+
// will be cached), just not for the wire below the threshold.
|
|
343
|
+
const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
|
|
344
|
+
const worthSendingGzip = clientWantsGzip && serialized.length > 1024;
|
|
345
|
+
const willCache = args.solveCache != null;
|
|
210
346
|
let compressed: Buffer | null = null;
|
|
211
347
|
let gzipMs = 0;
|
|
212
|
-
if (
|
|
348
|
+
if (worthSendingGzip || willCache) {
|
|
213
349
|
const gzipStart = performance.now();
|
|
214
|
-
compressed =
|
|
350
|
+
compressed = await gzipAsync(Buffer.from(serialized));
|
|
215
351
|
gzipMs = performance.now() - gzipStart;
|
|
216
352
|
}
|
|
217
353
|
|
|
354
|
+
// --- L2 write-through (H1) ----------------------------------------------
|
|
355
|
+
// On a cacheable solve with a compressed body, store the gzipped envelope so
|
|
356
|
+
// future hits are near-CPU-free. Fire-and-forget through the hook; the store
|
|
357
|
+
// itself is best-effort (may silently drop under quota/byte pressure).
|
|
358
|
+
if (args.solveCache && inputKey && compressed) {
|
|
359
|
+
const entry = encodeSolveCacheEntry(
|
|
360
|
+
{
|
|
361
|
+
errorCount: result.errors?.length ?? 0,
|
|
362
|
+
warningCount: result.warnings?.length ?? 0,
|
|
363
|
+
serializedBytes: serialized.length,
|
|
364
|
+
inputHash: inputKey
|
|
365
|
+
},
|
|
366
|
+
new Uint8Array(compressed)
|
|
367
|
+
);
|
|
368
|
+
args.solveCache.store(inputKey, entry);
|
|
369
|
+
}
|
|
370
|
+
|
|
218
371
|
const serverTotalMs = performance.now() - args.loadStartMs;
|
|
219
372
|
|
|
220
373
|
// --- per-request telemetry attribution (see snapshot above) -------------
|
|
@@ -228,6 +381,11 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
228
381
|
? client.rhinoTiming.last
|
|
229
382
|
: null;
|
|
230
383
|
|
|
384
|
+
// Send gzip to the client only when it's worth it for the wire — a body
|
|
385
|
+
// compressed solely to feed the L2 write-through (or one below the 1 KB
|
|
386
|
+
// break-even) goes out uncompressed.
|
|
387
|
+
const sendCompressed = compressed && worthSendingGzip ? compressed : null;
|
|
388
|
+
|
|
231
389
|
// --- Server-Timing envelope (the versioned wire contract) ---------------
|
|
232
390
|
const serverTiming = buildServerTiming({
|
|
233
391
|
defLoadMs: args.defLoadMs,
|
|
@@ -238,6 +396,9 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
238
396
|
serverTotalMs,
|
|
239
397
|
rhino,
|
|
240
398
|
settle,
|
|
399
|
+
byteRefOutcome: args.byteRefOutcome,
|
|
400
|
+
// L2 was consulted (hook present) and missed — otherwise not consulted.
|
|
401
|
+
l2CacheHit: args.solveCache ? false : null,
|
|
241
402
|
prepMarks: args.prepMarks ?? []
|
|
242
403
|
});
|
|
243
404
|
|
|
@@ -255,16 +416,23 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
255
416
|
gzipMs,
|
|
256
417
|
serverTotalMs,
|
|
257
418
|
serializedBytes: serialized.length,
|
|
258
|
-
compressedBytes:
|
|
419
|
+
compressedBytes: sendCompressed?.byteLength ?? null,
|
|
420
|
+
l2CacheHit: args.solveCache ? false : null
|
|
259
421
|
};
|
|
260
422
|
|
|
261
423
|
let envelope: SolveEnvelope;
|
|
262
|
-
if (
|
|
424
|
+
if (sendCompressed) {
|
|
263
425
|
// A downstream proxy's `encode gzip` skips already-encoded responses, so
|
|
264
426
|
// this never double-compresses. Vary is set on both branches.
|
|
265
427
|
headers['Content-Encoding'] = 'gzip';
|
|
266
|
-
headers['Content-Length'] = String(
|
|
267
|
-
envelope = {
|
|
428
|
+
headers['Content-Length'] = String(sendCompressed.byteLength);
|
|
429
|
+
envelope = {
|
|
430
|
+
body: new Uint8Array(sendCompressed),
|
|
431
|
+
encoding: 'gzip',
|
|
432
|
+
headers,
|
|
433
|
+
result,
|
|
434
|
+
metrics
|
|
435
|
+
};
|
|
268
436
|
} else {
|
|
269
437
|
headers['Content-Length'] = String(Buffer.byteLength(serialized));
|
|
270
438
|
envelope = { body: serialized, headers, result, metrics };
|
|
@@ -279,6 +447,84 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
279
447
|
};
|
|
280
448
|
}
|
|
281
449
|
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// L2 cache-hit envelope
|
|
452
|
+
// ============================================================================
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build the `ok` outcome from a stored L2 entry — the near-CPU-free hit path (no
|
|
456
|
+
* solve, no `JSON.stringify`, no gzip). Returns `null` when the entry is corrupt
|
|
457
|
+
* or its stored `inputHash` doesn't match the requesting key (defense-in-depth
|
|
458
|
+
* against a wide-hash collision), so the caller falls through and solves.
|
|
459
|
+
*/
|
|
460
|
+
function buildEnvelopeFromCacheEntry(
|
|
461
|
+
entryBytes: Uint8Array,
|
|
462
|
+
inputKey: string,
|
|
463
|
+
args: SolvePipelineArgs
|
|
464
|
+
): (SolveOutcome & { kind: 'ok' }) | null {
|
|
465
|
+
const decoded = decodeSolveCacheEntry(entryBytes);
|
|
466
|
+
// A collision that produced the same storage key still can't serve the wrong
|
|
467
|
+
// geometry: the stored hash must match the requesting key's hash.
|
|
468
|
+
if (!decoded || decoded.header.inputHash !== inputKey) return null;
|
|
469
|
+
|
|
470
|
+
const { header, gzippedBody } = decoded;
|
|
471
|
+
const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
|
|
472
|
+
const serverTotalMs = performance.now() - args.loadStartMs;
|
|
473
|
+
|
|
474
|
+
const serverTiming = buildServerTiming({
|
|
475
|
+
defLoadMs: args.defLoadMs,
|
|
476
|
+
treeBuildMs: 0,
|
|
477
|
+
solveMs: 0,
|
|
478
|
+
serializeMs: 0,
|
|
479
|
+
gzipMs: 0,
|
|
480
|
+
serverTotalMs,
|
|
481
|
+
rhino: null,
|
|
482
|
+
settle: null,
|
|
483
|
+
byteRefOutcome: args.byteRefOutcome,
|
|
484
|
+
l2CacheHit: true,
|
|
485
|
+
prepMarks: args.prepMarks ?? []
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const headers: Record<string, string> = {
|
|
489
|
+
'Content-Type': 'application/json',
|
|
490
|
+
'Server-Timing': serverTiming,
|
|
491
|
+
[COMPUTE_VERSION_HEADER]: String(COMPUTE_CONTRACT_VERSION),
|
|
492
|
+
Vary: 'Accept-Encoding'
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const metrics: SolvePhaseMetrics = {
|
|
496
|
+
treeBuildMs: 0,
|
|
497
|
+
solveMs: 0,
|
|
498
|
+
serializeMs: 0,
|
|
499
|
+
gzipMs: 0,
|
|
500
|
+
serverTotalMs,
|
|
501
|
+
serializedBytes: header.serializedBytes,
|
|
502
|
+
compressedBytes: clientWantsGzip ? gzippedBody.byteLength : null,
|
|
503
|
+
l2CacheHit: true
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
let envelope: SolveEnvelope;
|
|
507
|
+
if (clientWantsGzip) {
|
|
508
|
+
// Serve the stored compressed bytes verbatim — the whole point of R6.
|
|
509
|
+
headers['Content-Encoding'] = 'gzip';
|
|
510
|
+
headers['Content-Length'] = String(gzippedBody.byteLength);
|
|
511
|
+
envelope = { body: new Uint8Array(gzippedBody), encoding: 'gzip', headers, metrics };
|
|
512
|
+
} else {
|
|
513
|
+
// Rare non-gzip client: gunzip back to the JSON string for the response.
|
|
514
|
+
const json = gunzipEntryBody(gzippedBody);
|
|
515
|
+
headers['Content-Length'] = String(Buffer.byteLength(json));
|
|
516
|
+
envelope = { body: json, headers, metrics };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
kind: 'ok',
|
|
521
|
+
envelope,
|
|
522
|
+
solveMs: 0,
|
|
523
|
+
errorCount: header.errorCount,
|
|
524
|
+
warningCount: header.warningCount
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
282
528
|
// ============================================================================
|
|
283
529
|
// Server-Timing assembly
|
|
284
530
|
// ============================================================================
|
|
@@ -292,6 +538,9 @@ interface ServerTimingParts {
|
|
|
292
538
|
serverTotalMs: number;
|
|
293
539
|
rhino: CachedClient['rhinoTiming']['last'];
|
|
294
540
|
settle: CachedClient['solveMeta']['last'];
|
|
541
|
+
byteRefOutcome?: ByteRefOutcome;
|
|
542
|
+
/** L2 verdict: true=hit, false=miss (consulted), null=not consulted. */
|
|
543
|
+
l2CacheHit: boolean | null;
|
|
295
544
|
prepMarks: [string, number][];
|
|
296
545
|
}
|
|
297
546
|
|
|
@@ -335,5 +584,20 @@ function buildServerTiming(parts: ServerTimingParts): string {
|
|
|
335
584
|
}
|
|
336
585
|
}
|
|
337
586
|
|
|
587
|
+
// Byte-cache verdict for `DefinitionRef` solves: skipped = the scheduler never
|
|
588
|
+
// materialized bytes (pointer-known solve — the whole point of this path), hit
|
|
589
|
+
// = warm byte cache, miss = fell through to storage. Absent for raw-bytes solves.
|
|
590
|
+
const byteRef = parts.byteRefOutcome;
|
|
591
|
+
if (byteRef) {
|
|
592
|
+
const verdict = !byteRef.loaded ? 'skipped' : byteRef.fromCache ? 'hit' : 'miss';
|
|
593
|
+
header += `, def_bytes;desc=${verdict}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Durable L2 verdict (H1): 1 = hit (no solve ran), 0 = miss (consulted then
|
|
597
|
+
// solved). Absent when the L2 wasn't consulted (draft channel / caching off).
|
|
598
|
+
if (parts.l2CacheHit !== null) {
|
|
599
|
+
header += `, l2_cache;dur=${parts.l2CacheHit ? 1 : 0}`;
|
|
600
|
+
}
|
|
601
|
+
|
|
338
602
|
return header;
|
|
339
603
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `DefinitionService.uploadVersion` version-number allocation (caching
|
|
3
|
+
* Phase 1.5). The root-cause fix: the next version number comes from the store's
|
|
4
|
+
* monotonic `reserveNextVersionNumber` counter — NOT max(existing)+1, which
|
|
5
|
+
* reused a number (and its `fileKey`) after delete-latest and let a stale storage
|
|
6
|
+
* blob serve the old version's bytes for new content.
|
|
7
|
+
*
|
|
8
|
+
* These run against fakes that record the store/storage calls, so they pin the
|
|
9
|
+
* SERVICE's contract (reserve → derive fileKey from the reserved number). That a
|
|
10
|
+
* real provider's counter is truly monotonic across delete-then-reupload is
|
|
11
|
+
* covered by the definition-store conformance suite (both providers).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import type {
|
|
16
|
+
IDataProvider,
|
|
17
|
+
IStorageProvider,
|
|
18
|
+
RequestContext,
|
|
19
|
+
DefinitionRecord,
|
|
20
|
+
DefinitionVersion,
|
|
21
|
+
UISchema
|
|
22
|
+
} from '@selvajs/platform';
|
|
23
|
+
import { DefinitionService } from '../definition-service.js';
|
|
24
|
+
|
|
25
|
+
const CTX = { userId: 'u-1' } as RequestContext;
|
|
26
|
+
const SCHEMA = {} as UISchema;
|
|
27
|
+
|
|
28
|
+
function fakeRecord(guid: string, nextVersionNumber: number): DefinitionRecord {
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
return {
|
|
31
|
+
guid,
|
|
32
|
+
projectId: 'p-1',
|
|
33
|
+
ownerId: 'u-1',
|
|
34
|
+
createdBy: 'u-1',
|
|
35
|
+
updatedBy: 'u-1',
|
|
36
|
+
displayName: 'Def',
|
|
37
|
+
status: 'draft',
|
|
38
|
+
solveCount: 0,
|
|
39
|
+
nextVersionNumber,
|
|
40
|
+
liveVersionId: 'v-existing',
|
|
41
|
+
draftVersionId: 'v-existing',
|
|
42
|
+
createdAt: now,
|
|
43
|
+
updatedAt: now,
|
|
44
|
+
deletedAt: null
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A minimal data provider whose `definitions` store hands out the reserved
|
|
50
|
+
* number and records the version row it was told to create. Only the methods
|
|
51
|
+
* `uploadVersion` touches are implemented.
|
|
52
|
+
*/
|
|
53
|
+
function fakeDeps(record: DefinitionRecord) {
|
|
54
|
+
let counter = record.nextVersionNumber;
|
|
55
|
+
const created: DefinitionVersion[] = [];
|
|
56
|
+
const puts: string[] = [];
|
|
57
|
+
|
|
58
|
+
const data = {
|
|
59
|
+
definitions: {
|
|
60
|
+
get: async () => record,
|
|
61
|
+
reserveNextVersionNumber: async () => counter++, // monotonic, never reused
|
|
62
|
+
createVersion: async (_ctx: RequestContext, v: DefinitionVersion) => {
|
|
63
|
+
created.push(v);
|
|
64
|
+
},
|
|
65
|
+
setDraftVersion: async () => {}
|
|
66
|
+
}
|
|
67
|
+
} as unknown as IDataProvider;
|
|
68
|
+
|
|
69
|
+
const storage = {
|
|
70
|
+
put: async (key: string) => {
|
|
71
|
+
puts.push(key);
|
|
72
|
+
}
|
|
73
|
+
} as unknown as IStorageProvider;
|
|
74
|
+
|
|
75
|
+
return { data, storage, created, puts };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('DefinitionService.uploadVersion — version-number allocation', () => {
|
|
79
|
+
it('reserves the next number from the record counter and derives the fileKey from it', async () => {
|
|
80
|
+
const record = fakeRecord('11111111-1111-1111-1111-111111111111', 3);
|
|
81
|
+
const { data, storage, created, puts } = fakeDeps(record);
|
|
82
|
+
const svc = new DefinitionService(data, storage);
|
|
83
|
+
|
|
84
|
+
const version = await svc.uploadVersion(
|
|
85
|
+
CTX,
|
|
86
|
+
record.guid,
|
|
87
|
+
new Uint8Array([1, 2, 3]),
|
|
88
|
+
'gh',
|
|
89
|
+
'model.gh',
|
|
90
|
+
SCHEMA
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(version.versionNumber).toBe(3);
|
|
94
|
+
// fileKey encodes the reserved number — so a fresh number ⇒ fresh key.
|
|
95
|
+
expect(version.fileKey).toContain('v3');
|
|
96
|
+
expect(puts).toEqual([version.fileKey]);
|
|
97
|
+
expect(created).toHaveLength(1);
|
|
98
|
+
expect(created[0].versionNumber).toBe(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('two consecutive uploads mint distinct numbers and distinct fileKeys', async () => {
|
|
102
|
+
const record = fakeRecord('22222222-2222-2222-2222-222222222222', 5);
|
|
103
|
+
const { data, storage } = fakeDeps(record);
|
|
104
|
+
const svc = new DefinitionService(data, storage);
|
|
105
|
+
|
|
106
|
+
const a = await svc.uploadVersion(CTX, record.guid, new Uint8Array([1]), 'gh', 'a.gh', SCHEMA);
|
|
107
|
+
const b = await svc.uploadVersion(CTX, record.guid, new Uint8Array([2]), 'gh', 'b.gh', SCHEMA);
|
|
108
|
+
|
|
109
|
+
expect(a.versionNumber).toBe(5);
|
|
110
|
+
expect(b.versionNumber).toBe(6);
|
|
111
|
+
expect(a.fileKey).not.toBe(b.fileKey);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does NOT derive the number from the version list (no listVersions call)', async () => {
|
|
115
|
+
// The old bug read the version list; the fix must not. A deps object with no
|
|
116
|
+
// listVersions still works — reserveNextVersionNumber is the only source.
|
|
117
|
+
const record = fakeRecord('33333333-3333-3333-3333-333333333333', 9);
|
|
118
|
+
const { data, storage } = fakeDeps(record);
|
|
119
|
+
const svc = new DefinitionService(data, storage);
|
|
120
|
+
|
|
121
|
+
const version = await svc.uploadVersion(
|
|
122
|
+
CTX,
|
|
123
|
+
record.guid,
|
|
124
|
+
new Uint8Array([1]),
|
|
125
|
+
'gh',
|
|
126
|
+
'a.gh',
|
|
127
|
+
SCHEMA
|
|
128
|
+
);
|
|
129
|
+
expect(version.versionNumber).toBe(9);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -52,7 +52,7 @@ export interface CreateDefinitionRecord {
|
|
|
52
52
|
* blob and is filtered out of list endpoints by default.
|
|
53
53
|
*
|
|
54
54
|
* uploadVersion — append-only:
|
|
55
|
-
* 1.
|
|
55
|
+
* 1. Reserve next versionNumber from the record's monotonic counter
|
|
56
56
|
* 2. Upload blob to versions/v{N}.{ext}
|
|
57
57
|
* 3. Insert DefinitionVersion row
|
|
58
58
|
* 4. Advance draft pointer (live unchanged)
|
|
@@ -90,6 +90,8 @@ export class DefinitionService {
|
|
|
90
90
|
computeServerId: input.computeServerId,
|
|
91
91
|
status: 'pending',
|
|
92
92
|
solveCount: 0,
|
|
93
|
+
// v1 is created below with versionNumber 1, so the next upload reserves 2.
|
|
94
|
+
nextVersionNumber: 2,
|
|
93
95
|
liveVersionId: null,
|
|
94
96
|
draftVersionId: null,
|
|
95
97
|
createdAt: now,
|
|
@@ -150,9 +152,11 @@ export class DefinitionService {
|
|
|
150
152
|
const existing = await this.data.definitions.get(ctx, guid);
|
|
151
153
|
if (!existing) throw new ProviderError(`Definition not found: ${guid}`, 404);
|
|
152
154
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
// Reserve the next version number from the record's monotonic counter — NOT
|
|
156
|
+
// max(existing)+1, which reused a number after delete-latest and collided
|
|
157
|
+
// the `fileKey` (stale blob served for new content). The counter only ever
|
|
158
|
+
// advances, so a delete-then-reupload gets a fresh number and fresh key.
|
|
159
|
+
const next = await this.data.definitions.reserveNextVersionNumber(ctx, guid);
|
|
156
160
|
|
|
157
161
|
const versionId = randomUUID();
|
|
158
162
|
const fileKey = definitionPaths.version(guid, next, ext);
|