@selvajs/server 0.2.0-beta.3 → 0.2.0-beta.5
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/LICENSE +1 -1
- package/README.md +40 -0
- package/dist/compute/client-cache.d.ts +7 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +12 -1
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/index.d.ts +2 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +2 -2
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +26 -5
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +51 -25
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/rate-limit.d.ts +40 -0
- package/dist/compute/rate-limit.d.ts.map +1 -1
- package/dist/compute/rate-limit.js +72 -1
- package/dist/compute/rate-limit.js.map +1 -1
- package/dist/compute/remote-definition.d.ts +7 -0
- package/dist/compute/remote-definition.d.ts.map +1 -1
- package/dist/compute/remote-definition.js +3 -1
- package/dist/compute/remote-definition.js.map +1 -1
- package/dist/compute/safe-url.d.ts +2 -1
- package/dist/compute/safe-url.d.ts.map +1 -1
- package/dist/compute/safe-url.js +31 -1
- package/dist/compute/safe-url.js.map +1 -1
- package/dist/compute/solve-cache-key.d.ts +5 -6
- package/dist/compute/solve-cache-key.d.ts.map +1 -1
- package/dist/compute/solve-cache-key.js +4 -3
- package/dist/compute/solve-cache-key.js.map +1 -1
- package/dist/compute/solve-cache-single-flight.d.ts +8 -1
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -1
- package/dist/compute/solve-cache-single-flight.js +4 -2
- package/dist/compute/solve-cache-single-flight.js.map +1 -1
- package/dist/compute/solve-pipeline.d.ts +31 -0
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +42 -1
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/definitions/load-for-render.d.ts +10 -1
- package/dist/definitions/load-for-render.d.ts.map +1 -1
- package/dist/definitions/load-for-render.js +33 -6
- package/dist/definitions/load-for-render.js.map +1 -1
- package/dist/logging/PinoLogger.d.ts +39 -0
- package/dist/logging/PinoLogger.d.ts.map +1 -0
- package/dist/logging/PinoLogger.js +189 -0
- package/dist/logging/PinoLogger.js.map +1 -0
- package/dist/logging/index.d.ts +3 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/logging/index.js +5 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/logging/requestId.d.ts +45 -0
- package/dist/logging/requestId.d.ts.map +1 -0
- package/dist/logging/requestId.js +82 -0
- package/dist/logging/requestId.js.map +1 -0
- package/dist/providers/create-selva-providers.d.ts +11 -1
- package/dist/providers/create-selva-providers.d.ts.map +1 -1
- package/dist/providers/create-selva-providers.js +21 -8
- package/dist/providers/create-selva-providers.js.map +1 -1
- package/package.json +23 -19
- package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
- package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
- package/dist/access/__tests__/project-access-input.test.js +0 -146
- package/dist/access/__tests__/project-access-input.test.js.map +0 -1
- package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
- package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/client-cache.test.js +0 -218
- package/dist/compute/__tests__/client-cache.test.js.map +0 -1
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts +0 -2
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/definition-byte-cache.test.js +0 -107
- package/dist/compute/__tests__/definition-byte-cache.test.js.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.js +0 -98
- package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.js +0 -114
- package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
- package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
- package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
- package/dist/compute/__tests__/safe-url.test.js +0 -71
- package/dist/compute/__tests__/safe-url.test.js.map +0 -1
- package/dist/compute/__tests__/solve-cache.test.d.ts +0 -13
- package/dist/compute/__tests__/solve-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-cache.test.js +0 -234
- package/dist/compute/__tests__/solve-cache.test.js.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +0 -557
- package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
- package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
- package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
- package/dist/compute/__tests__/transform-input.test.js +0 -120
- package/dist/compute/__tests__/transform-input.test.js.map +0 -1
- package/dist/definitions/__tests__/definition-service.test.d.ts +0 -14
- package/dist/definitions/__tests__/definition-service.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/definition-service.test.js +0 -95
- package/dist/definitions/__tests__/definition-service.test.js.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.js +0 -196
- package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
- package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
- package/dist/http/__tests__/body-size.test.d.ts +0 -2
- package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
- package/dist/http/__tests__/body-size.test.js +0 -24
- package/dist/http/__tests__/body-size.test.js.map +0 -1
- package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
- package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
- package/dist/http/__tests__/route-classifier.test.js +0 -54
- package/dist/http/__tests__/route-classifier.test.js.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.js +0 -44
- package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
- package/dist/http/__tests__/security-headers.test.d.ts +0 -2
- package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
- package/dist/http/__tests__/security-headers.test.js +0 -24
- package/dist/http/__tests__/security-headers.test.js.map +0 -1
- package/dist/ops/__tests__/semver.test.d.ts +0 -2
- package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
- package/dist/ops/__tests__/semver.test.js +0 -38
- package/dist/ops/__tests__/semver.test.js.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
- package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.js +0 -50
- package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
- package/src/access/__tests__/project-access-input.test.ts +0 -175
- package/src/access/index.ts +0 -9
- package/src/access/project-access-input.ts +0 -145
- package/src/compute/__tests__/client-cache.test.ts +0 -244
- package/src/compute/__tests__/definition-byte-cache.test.ts +0 -129
- package/src/compute/__tests__/rate-limit.test.ts +0 -106
- package/src/compute/__tests__/remote-definition.test.ts +0 -140
- package/src/compute/__tests__/safe-url.test.ts +0 -72
- package/src/compute/__tests__/solve-cache.test.ts +0 -268
- package/src/compute/__tests__/solve-pipeline.test.ts +0 -660
- package/src/compute/__tests__/transform-input.test.ts +0 -162
- package/src/compute/client-cache.ts +0 -321
- package/src/compute/definition-byte-cache.ts +0 -168
- package/src/compute/index.ts +0 -82
- package/src/compute/limits.ts +0 -237
- package/src/compute/memory-solve-cache.ts +0 -0
- package/src/compute/rate-limit.ts +0 -110
- package/src/compute/remote-definition.ts +0 -149
- package/src/compute/safe-url.ts +0 -186
- package/src/compute/solve-cache-envelope.ts +0 -98
- package/src/compute/solve-cache-key.ts +0 -85
- package/src/compute/solve-cache-single-flight.ts +0 -59
- package/src/compute/solve-pipeline.ts +0 -603
- package/src/compute/transform-input.ts +0 -66
- package/src/definitions/__tests__/definition-service.test.ts +0 -131
- package/src/definitions/__tests__/load-for-render.test.ts +0 -244
- package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
- package/src/definitions/definition-service.ts +0 -247
- package/src/definitions/index.ts +0 -22
- package/src/definitions/load-for-render.ts +0 -272
- package/src/definitions/schema-extraction.ts +0 -117
- package/src/errors/SentryErrorReporter.ts +0 -88
- package/src/errors/index.ts +0 -3
- package/src/http/__tests__/body-size.test.ts +0 -27
- package/src/http/__tests__/route-classifier.test.ts +0 -60
- package/src/http/__tests__/safe-redirect.test.ts +0 -47
- package/src/http/__tests__/security-headers.test.ts +0 -29
- package/src/http/body-size.ts +0 -24
- package/src/http/index.ts +0 -10
- package/src/http/redirect.ts +0 -16
- package/src/http/route-classifier.ts +0 -74
- package/src/http/security-headers.ts +0 -42
- package/src/index.ts +0 -27
- package/src/ops/__tests__/semver.test.ts +0 -42
- package/src/ops/index.ts +0 -3
- package/src/ops/semver.ts +0 -60
- package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
- package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
- package/src/providers/create-selva-providers.ts +0 -241
- package/src/providers/index.ts +0 -12
- package/src/tokens/__tests__/token-codec.test.ts +0 -63
- package/src/tokens/index.ts +0 -8
- package/src/tokens/token-codec.ts +0 -86
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `transformInputParameter` — the adapter that turns a persisted `SchemaInput`
|
|
3
|
-
* (plus the user's chosen value) into the `@selvajs/compute` `InputParam` that
|
|
4
|
-
* `TreeBuilder.fromInputParams` serializes for the solve.
|
|
5
|
-
*
|
|
6
|
-
* The adapter delegates to the package's `processInput`, so these assert the
|
|
7
|
-
* package-aligned behavior: correct `paramType` for every type (not the old
|
|
8
|
-
* coerce-everything-to-Text), and `default` carrying the user's value. Where a value/
|
|
9
|
-
* default is absent, `processInput` leaves `default` undefined and `fromInputParams`
|
|
10
|
-
* omits the input entirely — letting Grasshopper use its own internal default rather
|
|
11
|
-
* than forcing `''`/`false`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, it, expect } from 'vitest';
|
|
15
|
-
import { transformInputParameter } from '../transform-input.js';
|
|
16
|
-
import type { SchemaInput } from '@selvajs/schemas';
|
|
17
|
-
|
|
18
|
-
type Input = SchemaInput & { minimum?: number; maximum?: number; stepSize?: number };
|
|
19
|
-
|
|
20
|
-
function input(partial: Partial<Input>): Input {
|
|
21
|
-
return {
|
|
22
|
-
id: 'id-1',
|
|
23
|
-
nickname: 'Param',
|
|
24
|
-
paramType: 'text',
|
|
25
|
-
...partial
|
|
26
|
-
} as Input;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('transformInputParameter — identity fields', () => {
|
|
30
|
-
it('carries id, name, nickname, description through', () => {
|
|
31
|
-
const out = transformInputParameter(
|
|
32
|
-
input({ id: 'guid-x', nickname: 'Width', description: 'the width' }),
|
|
33
|
-
5
|
|
34
|
-
);
|
|
35
|
-
expect(out.id).toBe('guid-x');
|
|
36
|
-
expect(out.name).toBe('Width');
|
|
37
|
-
expect(out.nickname).toBe('Width');
|
|
38
|
-
expect(out.description).toBe('the width');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('defaults a blank nickname to null and an absent description to empty string', () => {
|
|
42
|
-
const out = transformInputParameter(input({ nickname: '', description: undefined }), 'v');
|
|
43
|
-
expect(out.nickname).toBeNull();
|
|
44
|
-
expect(out.description).toBe('');
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('transformInputParameter — number / integer', () => {
|
|
49
|
-
it('maps number with the user value winning over the schema default', () => {
|
|
50
|
-
const out = transformInputParameter(
|
|
51
|
-
input({ paramType: 'number', default: 1, minimum: 0, maximum: 10, stepSize: 0.5 }),
|
|
52
|
-
7
|
|
53
|
-
);
|
|
54
|
-
expect(out).toMatchObject({ paramType: 'Number', default: 7, minimum: 0, maximum: 10 });
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('falls back to the schema default when no value is supplied', () => {
|
|
58
|
-
const out = transformInputParameter(input({ paramType: 'number', default: 3 }), undefined);
|
|
59
|
-
expect(out.default).toBe(3);
|
|
60
|
-
});
|
|
61
|
-
|
|
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.
|
|
65
|
-
const out = transformInputParameter(
|
|
66
|
-
input({ paramType: 'integer', stepSize: 0.5, default: 2 }),
|
|
67
|
-
4
|
|
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);
|
|
74
|
-
expect(out).toMatchObject({ paramType: 'Integer', stepSize: 1, default: 4 });
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('transformInputParameter — text', () => {
|
|
79
|
-
it('maps text and keeps the user value', () => {
|
|
80
|
-
const out = transformInputParameter(input({ paramType: 'text', default: 'd' }), 'hello');
|
|
81
|
-
expect(out).toMatchObject({ paramType: 'Text', default: 'hello' });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('falls back to the schema default when no value is supplied', () => {
|
|
85
|
-
expect(
|
|
86
|
-
transformInputParameter(input({ paramType: 'text', default: 'd' }), undefined).default
|
|
87
|
-
).toBe('d');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('leaves default undefined when neither value nor schema default exist (input is then omitted)', () => {
|
|
91
|
-
// processInput does not force ''. fromInputParams filters undefined defaults, so the
|
|
92
|
-
// param is simply not sent and Grasshopper uses its own internal default.
|
|
93
|
-
expect(
|
|
94
|
-
transformInputParameter(input({ paramType: 'text', default: undefined }), undefined).default
|
|
95
|
-
).toBeUndefined();
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe('transformInputParameter — boolean', () => {
|
|
100
|
-
it('maps boolean and preserves an explicit false (the bug-prone case)', () => {
|
|
101
|
-
const out = transformInputParameter(input({ paramType: 'boolean', default: true }), false);
|
|
102
|
-
expect(out).toMatchObject({ paramType: 'Boolean', default: false });
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('falls back to the schema default when no value is supplied', () => {
|
|
106
|
-
expect(
|
|
107
|
-
transformInputParameter(input({ paramType: 'boolean', default: true }), undefined).default
|
|
108
|
-
).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('leaves default undefined when neither value nor schema default exist (input is then omitted)', () => {
|
|
112
|
-
expect(
|
|
113
|
-
transformInputParameter(input({ paramType: 'boolean', default: undefined }), undefined)
|
|
114
|
-
.default
|
|
115
|
-
).toBeUndefined();
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe('transformInputParameter — value-list / file / color / generic', () => {
|
|
120
|
-
// The regression that motivated the swap: these used to be silently coerced to Text.
|
|
121
|
-
// processInput now types them correctly. (The options map for ValueList isn't carried
|
|
122
|
-
// on SchemaInput, so `values` is empty and the selected value rides on `default` —
|
|
123
|
-
// which is exactly the string a contextual ValueList param consumes.)
|
|
124
|
-
it('maps valueList to ValueList, carrying the selected value', () => {
|
|
125
|
-
const out = transformInputParameter(input({ paramType: 'valueList', default: '0' }), '1');
|
|
126
|
-
expect(out).toMatchObject({ paramType: 'ValueList', default: '1' });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Regression: processInput has no dynamicValueList handler, so without the explicit
|
|
130
|
-
// mapping it fell to the Geometry fallback with a null default — and fromInputParams
|
|
131
|
-
// then DROPPED the input, so the user's selection never reached the definition.
|
|
132
|
-
// The plugin's GetDynamicValueListParameter reuses the ValueList wire contract
|
|
133
|
-
// (TypeName "ValueList"), so ValueList is the correct target type.
|
|
134
|
-
it('maps dynamicValueList to ValueList, carrying the selected value', () => {
|
|
135
|
-
const out = transformInputParameter(
|
|
136
|
-
input({ paramType: 'dynamicValueList', default: '0' }),
|
|
137
|
-
'3.5'
|
|
138
|
-
);
|
|
139
|
-
expect(out).toMatchObject({ paramType: 'ValueList', default: '3.5' });
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('maps dynamicValueList checklist (array value) to ValueList, carrying all selections', () => {
|
|
143
|
-
const out = transformInputParameter(input({ paramType: 'dynamicValueList' }), ['a', 'b']);
|
|
144
|
-
expect(out.paramType).toBe('ValueList');
|
|
145
|
-
expect(out.default).toEqual(['a', 'b']);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('maps file to File', () => {
|
|
149
|
-
const out = transformInputParameter(input({ paramType: 'file' }), { fileName: 'a.3dm' });
|
|
150
|
-
expect(out.paramType).toBe('File');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('maps color to Color, carrying the value', () => {
|
|
154
|
-
const out = transformInputParameter(input({ paramType: 'color' }), '255,0,0');
|
|
155
|
-
expect(out).toMatchObject({ paramType: 'Color', default: '255,0,0' });
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('maps generic to the package fallback type (Geometry), not Text', () => {
|
|
159
|
-
const out = transformInputParameter(input({ paramType: 'generic', default: 'd' }), undefined);
|
|
160
|
-
expect(out.paramType).toBe('Geometry');
|
|
161
|
-
});
|
|
162
|
-
});
|
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
GrasshopperClient,
|
|
3
|
-
enableDebugLogging,
|
|
4
|
-
type GrasshopperComputeConfig,
|
|
5
|
-
type SolveScheduler
|
|
6
|
-
} from '@selvajs/compute';
|
|
7
|
-
|
|
8
|
-
// ============================================================================
|
|
9
|
-
// Shared compute client cache
|
|
10
|
-
// ============================================================================
|
|
11
|
-
//
|
|
12
|
-
// One warm `GrasshopperClient` (+ its `SolveScheduler`) per distinct compute
|
|
13
|
-
// server, keyed by the server's **`id`** (ADR 0004 — a server's identity is its
|
|
14
|
-
// `id`, never its URL). Definitions can pin different servers (the app's
|
|
15
|
-
// `resolveServerForOrg` honors a per-definition pin → org default → global
|
|
16
|
-
// default), so this is deliberately a per-server LRU, NOT a single shared
|
|
17
|
-
// client — two definitions on two servers keep two warm clients; a churn of
|
|
18
|
-
// one-off servers is bounded by `maxCachedClients`.
|
|
19
|
-
//
|
|
20
|
-
// Both hot paths share this cache:
|
|
21
|
-
// - the solve endpoint uses the entry's `scheduler`;
|
|
22
|
-
// - the definition-viewer render path uses the entry's `client` for `getIO`.
|
|
23
|
-
// Sharing means a definition that was just solved renders from the same warm
|
|
24
|
-
// client (and vice-versa) instead of re-handshaking Rhino.Compute per page load.
|
|
25
|
-
//
|
|
26
|
-
// Staleness: keyed on `id`, a rotated URL/apiKey keeps the SAME key with stale
|
|
27
|
-
// connection details — so the config-write path MUST call `evict(id)` when a
|
|
28
|
-
// server's config changes (ADR 0004 Consequences). That replaces the old
|
|
29
|
-
// implicit "new key → LRU age-out" staleness the URL-keyed cache relied on.
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Opaque identity of a compute server. Callers never construct the branded
|
|
33
|
-
* string directly — they pass a resolved server config to `serverIdentity()`.
|
|
34
|
-
* Keeping this opaque from day one (ADR 0004 D1) means "a server is now a pool
|
|
35
|
-
* of URLs behind one id" stays an additive, non-breaking change.
|
|
36
|
-
*/
|
|
37
|
-
export type ServerIdentity = string & { readonly __brand: 'ServerIdentity' };
|
|
38
|
-
|
|
39
|
-
/** Minimal resolved-server shape the cache needs (a subset of the app's `ComputeServerConfig`). */
|
|
40
|
-
export interface ResolvedServer {
|
|
41
|
-
/** Stable identity — the cache key. */
|
|
42
|
-
id: string;
|
|
43
|
-
/** Base URL of the Rhino.Compute instance (a resolution detail of `id`). */
|
|
44
|
-
serverUrl: string;
|
|
45
|
-
/** Sent as `RhinoComputeKey`. */
|
|
46
|
-
apiKey?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Derive the opaque cache identity from a resolved server. Identity is the `id`. */
|
|
50
|
-
export function serverIdentity(server: Pick<ResolvedServer, 'id'>): ServerIdentity {
|
|
51
|
-
return server.id as ServerIdentity;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Per-request telemetry holders read by the caller right after a solve resolves
|
|
56
|
-
* to build a Server-Timing header. Populated by the client/scheduler callbacks.
|
|
57
|
-
*/
|
|
58
|
-
export interface CachedClient {
|
|
59
|
-
client: GrasshopperClient;
|
|
60
|
-
scheduler: SolveScheduler;
|
|
61
|
-
/**
|
|
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.
|
|
69
|
-
*/
|
|
70
|
-
rhinoTiming: { last: { decode: number; solve: number; encode: number } | null; seq: number };
|
|
71
|
-
/**
|
|
72
|
-
* Last solve's cache verdicts from the scheduler's onSettle: whether the
|
|
73
|
-
* in-process response cache served it (no compute call) and whether the
|
|
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`.
|
|
79
|
-
*/
|
|
80
|
-
solveMeta: {
|
|
81
|
-
last: { fromCache: boolean; definitionReuploaded?: boolean } | null;
|
|
82
|
-
seq: number;
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Config injected by the consuming app. Everything that used to be read from
|
|
88
|
-
* `$env` / `computeLimits` in the app module is passed in, so this module stays
|
|
89
|
-
* env-agnostic and testable.
|
|
90
|
-
*/
|
|
91
|
-
export interface ClientCacheConfig {
|
|
92
|
-
/** Per-solve timeout forwarded to the scheduler (`ComputeLimits.maxSolveDurationMs`). */
|
|
93
|
-
maxSolveDurationMs: number;
|
|
94
|
-
/** Ask Rhino.Compute to cache solve results and return them on identical repeats. */
|
|
95
|
-
cachesolve: boolean;
|
|
96
|
-
/** Also cache solves that reported GH errors (only meaningful with `cachesolve`). */
|
|
97
|
-
cacheerroredsolves: boolean;
|
|
98
|
-
/** Reference large definitions by server cache key (pointer) instead of re-uploading. */
|
|
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;
|
|
118
|
-
/** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
|
|
119
|
-
debug: boolean;
|
|
120
|
-
/** VERBOSE lib-level logging (full solve request/response incl. geometry). */
|
|
121
|
-
debugVerbose: boolean;
|
|
122
|
-
/** Max distinct warm clients before the LRU evicts the oldest. Default 16. */
|
|
123
|
-
maxCachedClients?: number;
|
|
124
|
-
/** Sink for the concise debug lines (the app wires `console.log`). */
|
|
125
|
-
onDebugLog?: (message: string) => void;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface ClientCache {
|
|
129
|
-
/**
|
|
130
|
-
* Get (or create) the warm client + scheduler for a resolved compute server,
|
|
131
|
-
* keyed by its `id`. `definitionGuid`, when present, is stamped as the
|
|
132
|
-
* `X-Selva-Definition` header on this client's outbound solve/IO requests —
|
|
133
|
-
* inert routing/telemetry metadata until a pool router exists (ADR 0004 D2).
|
|
134
|
-
*/
|
|
135
|
-
getClient(server: ResolvedServer, opts?: { definitionGuid?: string }): Promise<CachedClient>;
|
|
136
|
-
/**
|
|
137
|
-
* Dispose and drop the warm client for `id` (or a `ServerIdentity`). Called by
|
|
138
|
-
* the config-write path when a server's URL/key rotates so the next request
|
|
139
|
-
* rebuilds against fresh connection details (ADR 0004 Consequences).
|
|
140
|
-
*/
|
|
141
|
-
evict(id: string | ServerIdentity): void;
|
|
142
|
-
/** Dispose every warm client. Test seam / shutdown hook. */
|
|
143
|
-
disposeAll(): void;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const DEFAULT_MAX_CACHED_CLIENTS = 16;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Build a per-server warm-client cache. Each cache owns its own LRU `Map` keyed
|
|
150
|
-
* on server `id`.
|
|
151
|
-
*/
|
|
152
|
-
export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
153
|
-
const maxCachedClients = config.maxCachedClients ?? DEFAULT_MAX_CACHED_CLIENTS;
|
|
154
|
-
const cache = new Map<string, CachedClient>();
|
|
155
|
-
|
|
156
|
-
if (config.debugVerbose) enableDebugLogging();
|
|
157
|
-
|
|
158
|
-
const debugLog = (message: string) => {
|
|
159
|
-
if (config.debug) config.onDebugLog?.(message);
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
async function build(server: ResolvedServer, definitionGuid?: string): Promise<CachedClient> {
|
|
163
|
-
// Holders for per-request results from the client/scheduler callbacks; the
|
|
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 };
|
|
168
|
-
|
|
169
|
-
const clientConfig: GrasshopperComputeConfig = {
|
|
170
|
-
serverUrl: server.serverUrl,
|
|
171
|
-
apiKey: server.apiKey,
|
|
172
|
-
// Lib-level debug is the verbose one (full payload logs); keep it on the
|
|
173
|
-
// verbose flag so the concise cache logs can run without the noise.
|
|
174
|
-
debug: config.debugVerbose,
|
|
175
|
-
// Ask Rhino.Compute to cache solve results and return them on identical
|
|
176
|
-
// repeats (same definition + inputs), skipping the solve.
|
|
177
|
-
cachesolve: config.cachesolve,
|
|
178
|
-
// Opt-in: also cache solves that reported GH errors.
|
|
179
|
-
cacheerroredsolves: config.cacheerroredsolves,
|
|
180
|
-
// DEBUG: the server's per-request timing breakdown, which exposes the two
|
|
181
|
-
// SERVER-side caches at once — definition cache (pointer reuse) via
|
|
182
|
-
// `decode`, solve cache (cachesolve) via `solve`. Flagged below so the
|
|
183
|
-
// contrast is obvious in the logs.
|
|
184
|
-
onServerTiming: (t) => {
|
|
185
|
-
const decode = t.decode ?? 0;
|
|
186
|
-
const solve = t.solve ?? 0;
|
|
187
|
-
const encode = t.encode ?? 0;
|
|
188
|
-
// Always recorded (cheap): the caller reads this after scheduler.solve.
|
|
189
|
-
rhinoTiming.seq += 1;
|
|
190
|
-
rhinoTiming.last = { decode, solve, encode };
|
|
191
|
-
if (!config.debug) return;
|
|
192
|
-
// Heuristic thresholds: a genuine parse/solve is many ms; a cache hit
|
|
193
|
-
// is sub-millisecond. Only meaningful relative to the first (cold) solve.
|
|
194
|
-
const defCache =
|
|
195
|
-
decode <= 1 ? 'HIT (definition reused, no re-upload/parse)' : 'miss (full parse)';
|
|
196
|
-
const solveCache =
|
|
197
|
-
config.cachesolve && solve <= 1
|
|
198
|
-
? 'HIT (cached result, solve skipped)'
|
|
199
|
-
: config.cachesolve
|
|
200
|
-
? 'miss (solved fresh)'
|
|
201
|
-
: 'off';
|
|
202
|
-
debugLog(
|
|
203
|
-
`[Compute/rhino-cache] decode=${decode}ms solve=${solve}ms encode=${encode}ms ` +
|
|
204
|
-
`| definition-cache: ${defCache} | solve-cache (cachesolve): ${solveCache}`
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
// `X-Selva-Definition` on the wire (ADR 0004 D2). Baked at client-create
|
|
210
|
-
// time because the scheduler has no per-request header hook — inert for a
|
|
211
|
-
// single-member "pool", useful in compute-side access logs immediately.
|
|
212
|
-
if (definitionGuid) {
|
|
213
|
-
clientConfig.headers = { 'X-Selva-Definition': definitionGuid };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const client = await GrasshopperClient.create(clientConfig);
|
|
217
|
-
const scheduler = client.createScheduler({
|
|
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,
|
|
228
|
-
timeoutMs: config.maxSolveDurationMs,
|
|
229
|
-
cache: { maxEntries: 20, ttlMs: 5 * 60_000 },
|
|
230
|
-
// Solve large definitions by server cache-key (pointer) instead of
|
|
231
|
-
// re-uploading the full binary every solve. On a stale-pointer miss the
|
|
232
|
-
// client transparently re-uploads.
|
|
233
|
-
reuseServerDefinitionCache: config.reuseServerDefinitionCache,
|
|
234
|
-
// DEBUG: observe the in-process response cache. `fromCache === true` means
|
|
235
|
-
// this solve was served from the app's own cache — Rhino.Compute was never
|
|
236
|
-
// called. `false` means it went to the compute server.
|
|
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;
|
|
241
|
-
if (result.status !== 'success') return;
|
|
242
|
-
// Always recorded (cheap): the caller surfaces these on Server-Timing.
|
|
243
|
-
solveMeta.last = {
|
|
244
|
-
fromCache: result.fromCache === true,
|
|
245
|
-
definitionReuploaded: result.definitionReuploaded
|
|
246
|
-
};
|
|
247
|
-
if (!config.debug) return;
|
|
248
|
-
debugLog(
|
|
249
|
-
`[Compute/selva-cache] ${result.fromCache ? 'HIT — served from cache (no compute call)' : 'miss — went to Rhino.Compute'} (${Math.round(result.durationMs)}ms)`
|
|
250
|
-
);
|
|
251
|
-
// Definition-cache verdict for a real compute call (not a cache HIT).
|
|
252
|
-
if (!result.fromCache) {
|
|
253
|
-
debugLog(
|
|
254
|
-
result.definitionReuploaded === true
|
|
255
|
-
? '[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)'
|
|
256
|
-
: result.definitionReuploaded === false
|
|
257
|
-
? '[Compute/def-cache] HIT — definition reused from server cache (no upload)'
|
|
258
|
-
: '[Compute/def-cache] n/a — reuse disabled or non-reusable definition'
|
|
259
|
-
);
|
|
260
|
-
}
|
|
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).
|
|
264
|
-
if (!result.fromCache) {
|
|
265
|
-
const errs = (result.response as { errors?: unknown[] })?.errors;
|
|
266
|
-
const errCount = Array.isArray(errs) ? errs.length : 0;
|
|
267
|
-
if (errCount > 0) {
|
|
268
|
-
const cacheNote = config.cacheerroredsolves
|
|
269
|
-
? 'cached (cacheerroredsolves on)'
|
|
270
|
-
: 'not cached (cacheerroredsolves off) — re-run until fixed';
|
|
271
|
-
debugLog(
|
|
272
|
-
`[Compute/rhino-cache] ${errCount} GH error(s); ${cacheNote}. First: ${JSON.stringify((errs as unknown[])[0])}`
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
return { client, scheduler, rhinoTiming, solveMeta };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
async getClient(server, opts): Promise<CachedClient> {
|
|
283
|
-
const key = server.id;
|
|
284
|
-
const existing = cache.get(key);
|
|
285
|
-
if (existing) {
|
|
286
|
-
// Refresh LRU position.
|
|
287
|
-
cache.delete(key);
|
|
288
|
-
cache.set(key, existing);
|
|
289
|
-
return existing;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const entry = await build(server, opts?.definitionGuid);
|
|
293
|
-
|
|
294
|
-
// Evict the least-recently-used entry before inserting when at capacity.
|
|
295
|
-
if (cache.size >= maxCachedClients) {
|
|
296
|
-
const oldestKey = cache.keys().next().value;
|
|
297
|
-
if (oldestKey !== undefined) {
|
|
298
|
-
cache.get(oldestKey)?.scheduler.dispose();
|
|
299
|
-
cache.delete(oldestKey);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
cache.set(key, entry);
|
|
304
|
-
return entry;
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
evict(id): void {
|
|
308
|
-
const key = id as string;
|
|
309
|
-
const entry = cache.get(key);
|
|
310
|
-
if (entry) {
|
|
311
|
-
entry.scheduler.dispose();
|
|
312
|
-
cache.delete(key);
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
|
|
316
|
-
disposeAll(): void {
|
|
317
|
-
for (const entry of cache.values()) entry.scheduler.dispose();
|
|
318
|
-
cache.clear();
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-process cache of `.gh` definition bytes, keyed by immutable **version id**.
|
|
3
|
-
*
|
|
4
|
-
* The solve path used to eagerly `storage.get(fileKey)` on every request — moving
|
|
5
|
-
* multi-MB definition bytes off disk/S3 into memory before the scheduler could
|
|
6
|
-
* decide it didn't need them (the compute server already holds the definition via
|
|
7
|
-
* the pointer/`reuseServerDefinitionCache` fast path). This cache turns that eager
|
|
8
|
-
* read into a lazy `DefinitionRef.load()`: the scheduler calls `load()` ONLY when
|
|
9
|
-
* an upload is unavoidable, and even then a warm entry serves the bytes without
|
|
10
|
-
* touching storage. A pointer-known re-solve therefore moves ZERO definition bytes.
|
|
11
|
-
*
|
|
12
|
-
* Keying discipline (the whole reason this module exists):
|
|
13
|
-
* - The key is the **version id**, an immutable UUID — never a `fileKey`, which
|
|
14
|
-
* a delete-latest-then-reupload can REUSE for different content (see the
|
|
15
|
-
* monotonic-version-number fix). A reused key would serve one version's bytes
|
|
16
|
-
* for another: silent cache poisoning with no diagnostic. Callers pass
|
|
17
|
-
* `version.id`, and the returned `DefinitionRef.key` is that same id, so the
|
|
18
|
-
* scheduler's own result/pointer cache keys on the same immutable identity.
|
|
19
|
-
*
|
|
20
|
-
* Eviction:
|
|
21
|
-
* - LRU by **total byte budget**, not entry count — definitions range from tens
|
|
22
|
-
* of KB to hundreds of MB, so an entry-count cap would either pin gigabytes or
|
|
23
|
-
* evict uselessly. `COMPUTE_DEFINITION_BYTE_CACHE_MB` sets the budget; `0`
|
|
24
|
-
* disables the cache (every `getOrLoad` calls `load` and caches nothing).
|
|
25
|
-
* - No TTL: version ids are immutable, so a cached entry can never go stale.
|
|
26
|
-
* - An entry larger than the whole budget is served but never retained (caching
|
|
27
|
-
* it would immediately evict everything including itself).
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Per-`getOrLoad` outcome, observable AFTER the scheduler has (or hasn't) called
|
|
32
|
-
* `load()`. Lets the solve pipeline emit a `def_bytes` Server-Timing verdict:
|
|
33
|
-
* - `load` never invoked → the scheduler served a pointer-known solve without
|
|
34
|
-
* bytes (`skipped`);
|
|
35
|
-
* - `load` invoked, `fromCache` true → warm byte-cache entry (`hit`);
|
|
36
|
-
* - `load` invoked, `fromCache` false → fell through to the loader (`miss`).
|
|
37
|
-
*/
|
|
38
|
-
export interface ByteRefOutcome {
|
|
39
|
-
/** True once `load()` has been called at least once on this ref. */
|
|
40
|
-
loaded: boolean;
|
|
41
|
-
/** When `loaded`, whether the bytes came from a warm cache entry. */
|
|
42
|
-
fromCache: boolean;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** A definition reference shaped for `@selvajs/compute`'s `DefinitionRef`. */
|
|
46
|
-
export interface ByteCacheRef {
|
|
47
|
-
/** Immutable identity (a version UUID). Keys the scheduler's caches too. */
|
|
48
|
-
key: string;
|
|
49
|
-
/** Materialize the bytes — served from cache when warm, else via the loader. */
|
|
50
|
-
load: () => Promise<Uint8Array>;
|
|
51
|
-
/** Mutable outcome of this ref's `load()` (see {@link ByteRefOutcome}). */
|
|
52
|
-
outcome: ByteRefOutcome;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Hit/miss/eviction counters for observability (Server-Timing, admin debug). */
|
|
56
|
-
export interface ByteCacheStats {
|
|
57
|
-
/** `load()` served from a warm entry (no loader call). */
|
|
58
|
-
hits: number;
|
|
59
|
-
/** `load()` fell through to the loader (cold key, or caching disabled). */
|
|
60
|
-
misses: number;
|
|
61
|
-
/** Entries dropped by the byte-budget LRU. */
|
|
62
|
-
evictions: number;
|
|
63
|
-
/** Current retained entry count. */
|
|
64
|
-
entries: number;
|
|
65
|
-
/** Current retained bytes. */
|
|
66
|
-
bytes: number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface DefinitionByteCache {
|
|
70
|
-
/**
|
|
71
|
-
* Return a `DefinitionRef` whose `load()` serves `versionId`'s bytes from the
|
|
72
|
-
* cache when warm, otherwise calls `load` (and caches the result). The
|
|
73
|
-
* `DefinitionRef` is cheap to build and moves no bytes until `load()` runs —
|
|
74
|
-
* so handing it to the scheduler for a pointer-known solve costs nothing.
|
|
75
|
-
*/
|
|
76
|
-
getOrLoad(versionId: string, load: () => Promise<Uint8Array>): ByteCacheRef;
|
|
77
|
-
/** Snapshot of the counters (see {@link ByteCacheStats}). */
|
|
78
|
-
stats(): ByteCacheStats;
|
|
79
|
-
/** Drop everything (test seam / eviction on definition delete if ever needed). */
|
|
80
|
-
clear(): void;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface Entry {
|
|
84
|
-
bytes: Uint8Array;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Build a definition-byte cache with a total-byte LRU budget.
|
|
89
|
-
*
|
|
90
|
-
* @param maxBytes total retained-byte budget; `0` (or negative) disables caching
|
|
91
|
-
* entirely — every `load()` calls the loader and nothing is retained.
|
|
92
|
-
*/
|
|
93
|
-
export function createDefinitionByteCache(maxBytes: number): DefinitionByteCache {
|
|
94
|
-
const budget = Math.max(0, Math.floor(maxBytes));
|
|
95
|
-
// Map preserves insertion order → iteration order IS LRU order; re-inserting
|
|
96
|
-
// on access moves an entry to the most-recently-used (last) position.
|
|
97
|
-
const cache = new Map<string, Entry>();
|
|
98
|
-
let retainedBytes = 0;
|
|
99
|
-
const stats = { hits: 0, misses: 0, evictions: 0 };
|
|
100
|
-
|
|
101
|
-
function touch(key: string, entry: Entry): void {
|
|
102
|
-
cache.delete(key);
|
|
103
|
-
cache.set(key, entry);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function evictToFit(): void {
|
|
107
|
-
// Evict least-recently-used (first in insertion order) until within budget.
|
|
108
|
-
while (retainedBytes > budget && cache.size > 0) {
|
|
109
|
-
const oldestKey = cache.keys().next().value as string;
|
|
110
|
-
const oldest = cache.get(oldestKey)!;
|
|
111
|
-
cache.delete(oldestKey);
|
|
112
|
-
retainedBytes -= oldest.bytes.byteLength;
|
|
113
|
-
stats.evictions += 1;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function store(key: string, bytes: Uint8Array): void {
|
|
118
|
-
if (budget === 0) return;
|
|
119
|
-
// An entry bigger than the whole budget is never retained — caching it would
|
|
120
|
-
// evict everything (including itself) on the next `evictToFit`. Serve it
|
|
121
|
-
// through, cache nothing.
|
|
122
|
-
if (bytes.byteLength > budget) return;
|
|
123
|
-
const existing = cache.get(key);
|
|
124
|
-
if (existing) retainedBytes -= existing.bytes.byteLength;
|
|
125
|
-
cache.set(key, { bytes });
|
|
126
|
-
retainedBytes += bytes.byteLength;
|
|
127
|
-
evictToFit();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
getOrLoad(versionId, load) {
|
|
132
|
-
const outcome: ByteRefOutcome = { loaded: false, fromCache: false };
|
|
133
|
-
return {
|
|
134
|
-
key: versionId,
|
|
135
|
-
outcome,
|
|
136
|
-
load: async () => {
|
|
137
|
-
const hit = cache.get(versionId);
|
|
138
|
-
if (hit) {
|
|
139
|
-
touch(versionId, hit);
|
|
140
|
-
stats.hits += 1;
|
|
141
|
-
outcome.loaded = true;
|
|
142
|
-
outcome.fromCache = true;
|
|
143
|
-
return hit.bytes;
|
|
144
|
-
}
|
|
145
|
-
stats.misses += 1;
|
|
146
|
-
outcome.loaded = true;
|
|
147
|
-
outcome.fromCache = false;
|
|
148
|
-
const bytes = await load();
|
|
149
|
-
store(versionId, bytes);
|
|
150
|
-
return bytes;
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
},
|
|
154
|
-
stats() {
|
|
155
|
-
return {
|
|
156
|
-
hits: stats.hits,
|
|
157
|
-
misses: stats.misses,
|
|
158
|
-
evictions: stats.evictions,
|
|
159
|
-
entries: cache.size,
|
|
160
|
-
bytes: retainedBytes
|
|
161
|
-
};
|
|
162
|
-
},
|
|
163
|
-
clear() {
|
|
164
|
-
cache.clear();
|
|
165
|
-
retainedBytes = 0;
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
}
|