@spendgraph/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -0
- package/bin/harness.mjs +76 -0
- package/dist/core/budget.d.ts +46 -0
- package/dist/core/budget.js +72 -0
- package/dist/core/cache.d.ts +119 -0
- package/dist/core/cache.js +206 -0
- package/dist/core/client.d.ts +33 -0
- package/dist/core/client.js +98 -0
- package/dist/core/errors.d.ts +22 -0
- package/dist/core/errors.js +28 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/pull.d.ts +17 -0
- package/dist/core/pull.js +38 -0
- package/dist/core/schema/serialize.d.ts +12 -0
- package/dist/core/schema/serialize.js +42 -0
- package/dist/core/schema/types.d.ts +58 -0
- package/dist/core/schema/types.js +1 -0
- package/dist/core/schema/validate.d.ts +28 -0
- package/dist/core/schema/validate.js +98 -0
- package/dist/harness.d.ts +128 -0
- package/dist/harness.js +187 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/prompt/codegen.d.ts +37 -0
- package/dist/prompt/codegen.js +97 -0
- package/dist/prompt/render.d.ts +35 -0
- package/dist/prompt/render.js +66 -0
- package/dist/prompt/run.d.ts +38 -0
- package/dist/prompt/run.js +96 -0
- package/dist/prompt/types.d.ts +156 -0
- package/dist/prompt/types.js +1 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# @spendgraph/harness
|
|
2
|
+
|
|
3
|
+
Pull a stored prompt, render it, report what it cost. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
Your prompt lives in spendgraph, not in your codebase. This client fetches it,
|
|
6
|
+
substitutes its typed fields, and records what the call cost — without sitting
|
|
7
|
+
in your request path.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @spendgraph/harness
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The production path
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Harness } from "@spendgraph/harness";
|
|
17
|
+
|
|
18
|
+
const harness = new Harness({
|
|
19
|
+
apiKey: process.env.SPENDGRAPH_API_KEY,
|
|
20
|
+
baseUrl: "https://your-spendgraph.example.com",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export async function handle(request) {
|
|
24
|
+
const prompt = await harness.pull("billing-explainer-k3f9x2");
|
|
25
|
+
|
|
26
|
+
const values = { question: "why was I charged twice?" };
|
|
27
|
+
const messages = prompt.render(values);
|
|
28
|
+
|
|
29
|
+
// Call the provider yourself. spendgraph is never between you and the model.
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const completion = await yourModelCall(messages);
|
|
32
|
+
|
|
33
|
+
await harness.report(prompt.id, {
|
|
34
|
+
versionId: prompt.versionId,
|
|
35
|
+
model: "anthropic/claude-sonnet-5",
|
|
36
|
+
fields: prompt.serialize(values),
|
|
37
|
+
rendered: messages,
|
|
38
|
+
output: completion.text,
|
|
39
|
+
inputTokens: completion.usage.inputTokens,
|
|
40
|
+
outputTokens: completion.usage.outputTokens,
|
|
41
|
+
cacheReadTokens: completion.usage.cacheReadTokens ?? 0,
|
|
42
|
+
cacheWriteTokens: completion.usage.cacheWriteTokens ?? 0,
|
|
43
|
+
latencyMs: Date.now() - startedAt,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return completion.text;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Call `pull` inside the handler, not at module scope. It refreshes as a side
|
|
51
|
+
effect of being called, so a service that pulls once at boot serves that wording
|
|
52
|
+
for the life of the process. Called per request it is a cache read, not a round
|
|
53
|
+
trip.
|
|
54
|
+
|
|
55
|
+
`report` never throws and never rejects — it sits beside a request that has
|
|
56
|
+
already been answered, and telemetry that can break the thing it measures is
|
|
57
|
+
worse than no telemetry. Pass `onReportError` if you want to know when one is
|
|
58
|
+
dropped.
|
|
59
|
+
|
|
60
|
+
## Handles
|
|
61
|
+
|
|
62
|
+
`pull`, `run` and the rest take a prompt's uuid or its slug.
|
|
63
|
+
|
|
64
|
+
A slug is derived from the prompt's name, so **renaming the prompt mints a new
|
|
65
|
+
one and the old stops resolving**. The uuid never changes. Pull by slug for
|
|
66
|
+
something readable; pull by id where the handle has to outlive an edit to the
|
|
67
|
+
name.
|
|
68
|
+
|
|
69
|
+
When a handle stops resolving, the client keeps serving the wording it last saw
|
|
70
|
+
rather than failing your request — and backs off rather than retrying on every
|
|
71
|
+
call. Pass `onPullError` to be told, because otherwise that is silent:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
new Harness({
|
|
75
|
+
apiKey,
|
|
76
|
+
baseUrl,
|
|
77
|
+
onPullError: (err, handle) => log.warn({ err, handle }, "prompt refresh failed"),
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Caching
|
|
82
|
+
|
|
83
|
+
`pull` is stale-while-revalidate. A fresh entry is returned outright, a stale
|
|
84
|
+
one is returned *now* and refreshed behind you, and only a cold miss waits on
|
|
85
|
+
the network. So an edit in the dashboard reaches production within a TTL without
|
|
86
|
+
any request paying for the fetch.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
new Harness({ apiKey, baseUrl, cache: { ttlSeconds: 300, maxSize: 100 } });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`harness.invalidate(promptId)` drops it immediately — `promote()` does this for
|
|
93
|
+
you, including for entries cached under the other handle.
|
|
94
|
+
|
|
95
|
+
## The batch path
|
|
96
|
+
|
|
97
|
+
`run`, `sample` and `runAll` execute the prompt server-side and record the
|
|
98
|
+
rollout in one call. That adds a hop and makes spendgraph a dependency of your
|
|
99
|
+
uptime, which is the right trade for evaluation and the wrong one for a user
|
|
100
|
+
waiting on a response.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const result = await harness.run("billing-explainer-k3f9x2", values);
|
|
104
|
+
const k = await harness.sample(promptId, values, { k: 5 }); // pass^k
|
|
105
|
+
const across = await harness.runAll(promptId, values); // one per model
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Cap what a client can spend:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
new Harness({ apiKey, baseUrl, maxCostMicros: 5_000_000 }); // $5
|
|
112
|
+
harness.spent(); // micro-dollars used
|
|
113
|
+
harness.remaining(); // what is left
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Omit `maxCostMicros` and it warns once that it is unbounded. Pass `null` to say
|
|
117
|
+
you meant it.
|
|
118
|
+
|
|
119
|
+
## Typed fields
|
|
120
|
+
|
|
121
|
+
A prompt declares its placeholders — `string`, `text`, `number`, `boolean`,
|
|
122
|
+
`enum`, `list`, `json` — and `render` validates before anything is sent, so a
|
|
123
|
+
missing required field is an exception rather than a `{placeholder}` reaching
|
|
124
|
+
the model.
|
|
125
|
+
|
|
126
|
+
`harness codegen` turns those declarations into types:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
npx harness codegen --url https://your-spendgraph.example.com
|
|
130
|
+
# usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
It reads `SPENDGRAPH_API_KEY` and `SPENDGRAPH_URL` from the environment and
|
|
134
|
+
writes `src/harness.generated.d.ts`. Include that file and the values argument
|
|
135
|
+
is checked per prompt, by id and by slug. Delete it and calls fall back to a
|
|
136
|
+
loose `Record<string, unknown>` — the runtime validation is unchanged either
|
|
137
|
+
way.
|
|
138
|
+
|
|
139
|
+
## Versions
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const { versions, currentVersionId } = await harness.versions(promptId);
|
|
143
|
+
await harness.promote(promptId, versionId);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Promoting changes what every `pull` serves from that moment, and invalidates the
|
|
147
|
+
cache so the change is visible immediately rather than after a TTL.
|
|
148
|
+
|
|
149
|
+
## Datasets
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const { cases, counts } = await harness.cases(promptId);
|
|
153
|
+
await harness.setCases(promptId, cases);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`setCases` replaces the whole set. A dataset answers "what am I evaluating
|
|
157
|
+
against" with one answer at a time, and a merge would leave no way to remove a
|
|
158
|
+
case.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
package/bin/harness.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* harness codegen — writes a .d.ts describing every stored prompt's fields.
|
|
4
|
+
*
|
|
5
|
+
* SPENDGRAPH_API_KEY=sg_… SPENDGRAPH_URL=https://costs.example.com \
|
|
6
|
+
* npx harness codegen --out src/harness.generated.d.ts
|
|
7
|
+
*
|
|
8
|
+
* Reads through the paginated list endpoint, so a project with hundreds of
|
|
9
|
+
* prompts is many small requests rather than one enormous response.
|
|
10
|
+
*/
|
|
11
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { emitPromptTypes } from "../dist/index.js";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const command = args[0];
|
|
17
|
+
|
|
18
|
+
function flag(name, fallback) {
|
|
19
|
+
const i = args.indexOf(`--${name}`);
|
|
20
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fail(message) {
|
|
24
|
+
console.error(`harness: ${message}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (command !== "codegen") {
|
|
29
|
+
console.log("usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]");
|
|
30
|
+
process.exit(command ? 1 : 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const apiKey = process.env.SPENDGRAPH_API_KEY;
|
|
34
|
+
const baseUrl = (process.env.SPENDGRAPH_URL ?? flag("url", "")).replace(/\/+$/, "");
|
|
35
|
+
if (!apiKey) fail("set SPENDGRAPH_API_KEY");
|
|
36
|
+
if (!baseUrl) fail("set SPENDGRAPH_URL, or pass --url");
|
|
37
|
+
|
|
38
|
+
const out = resolve(flag("out", "src/harness.generated.d.ts"));
|
|
39
|
+
const project = flag("project", "");
|
|
40
|
+
const moduleName = flag("module", "@spendgraph/harness");
|
|
41
|
+
|
|
42
|
+
const prompts = [];
|
|
43
|
+
let cursor = null;
|
|
44
|
+
|
|
45
|
+
// Bounded: a server that kept handing back a cursor must not spin forever.
|
|
46
|
+
for (let page = 0; page < 200; page++) {
|
|
47
|
+
// Archived prompts included on purpose. The dashboard hides them, but a
|
|
48
|
+
// generated type disappearing because somebody tidied a list would break the
|
|
49
|
+
// build of code that still pulls the prompt — archiving does not stop it
|
|
50
|
+
// serving, so it must not stop it type-checking either.
|
|
51
|
+
const params = new URLSearchParams({ limit: "200", archived: "all" });
|
|
52
|
+
if (project) params.set("project", project);
|
|
53
|
+
if (cursor) params.set("cursor", cursor);
|
|
54
|
+
|
|
55
|
+
const res = await fetch(`${baseUrl}/api/v1/prompts?${params}`, {
|
|
56
|
+
headers: { "x-api-key": apiKey },
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text().catch(() => "");
|
|
60
|
+
fail(`${res.status} listing prompts — ${body.slice(0, 200)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const body = await res.json();
|
|
64
|
+
prompts.push(...(body.prompts ?? []));
|
|
65
|
+
cursor = body.nextCursor;
|
|
66
|
+
if (!cursor) break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await mkdir(dirname(out), { recursive: true });
|
|
70
|
+
await writeFile(out, emitPromptTypes(prompts, { module: moduleName }));
|
|
71
|
+
|
|
72
|
+
const named = prompts.filter((p) => (p.fieldSpec ?? []).some((f) => f.required)).length;
|
|
73
|
+
console.log(
|
|
74
|
+
`harness: wrote ${prompts.length} prompt${prompts.length === 1 ? "" : "s"} to ${out}` +
|
|
75
|
+
(named ? ` (${named} with required fields)` : "")
|
|
76
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when a client has spent its ceiling.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a `HarnessError`: nothing was refused by the server, so a
|
|
5
|
+
* caller inspecting `status` or `retryable` would be told a story about HTTP
|
|
6
|
+
* that never happened.
|
|
7
|
+
*/
|
|
8
|
+
export declare class BudgetExceededError extends Error {
|
|
9
|
+
readonly spentMicros: number;
|
|
10
|
+
readonly limitMicros: number;
|
|
11
|
+
constructor(spentMicros: number, limitMicros: number);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* What this client has spent, and whether it may spend more.
|
|
15
|
+
*
|
|
16
|
+
* A loop that samples k rollouts across a dataset is the shape of an expensive
|
|
17
|
+
* accident: one bad `k`, one dataset larger than expected, and the bill arrives
|
|
18
|
+
* before anyone notices. The guard is a counter and a comparison, and a cost
|
|
19
|
+
* meter that shipped without one would be embarrassing.
|
|
20
|
+
*
|
|
21
|
+
* Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
|
|
22
|
+
* not gate `report`, which records money the caller already spent elsewhere;
|
|
23
|
+
* counting that would make the ceiling mean two different things at once.
|
|
24
|
+
*/
|
|
25
|
+
export declare class Budget {
|
|
26
|
+
private used;
|
|
27
|
+
private readonly limit;
|
|
28
|
+
/**
|
|
29
|
+
* `undefined` means no ceiling and warns once — an unbounded client in a
|
|
30
|
+
* scheduled script is how a surprise bill happens. `null` means the same
|
|
31
|
+
* thing deliberately, and says nothing.
|
|
32
|
+
*/
|
|
33
|
+
constructor(maxCostMicros?: number | null);
|
|
34
|
+
spent(): number;
|
|
35
|
+
remaining(): number;
|
|
36
|
+
add(costMicros: number): void;
|
|
37
|
+
/**
|
|
38
|
+
* Throws if the ceiling is already reached.
|
|
39
|
+
*
|
|
40
|
+
* Checked before a request rather than after: stopping once the bill has been
|
|
41
|
+
* incurred is a report, not a limit.
|
|
42
|
+
*/
|
|
43
|
+
assertAffordable(): void;
|
|
44
|
+
}
|
|
45
|
+
/** Test seam: lets the once-only warning be exercised more than once. */
|
|
46
|
+
export declare function resetBudgetWarning(): void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when a client has spent its ceiling.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a `HarnessError`: nothing was refused by the server, so a
|
|
5
|
+
* caller inspecting `status` or `retryable` would be told a story about HTTP
|
|
6
|
+
* that never happened.
|
|
7
|
+
*/
|
|
8
|
+
export class BudgetExceededError extends Error {
|
|
9
|
+
spentMicros;
|
|
10
|
+
limitMicros;
|
|
11
|
+
constructor(spentMicros, limitMicros) {
|
|
12
|
+
super(`Cost ceiling reached: spent $${(spentMicros / 1e6).toFixed(4)} of ` +
|
|
13
|
+
`$${(limitMicros / 1e6).toFixed(4)}. Raise maxCostMicros or start a new client.`);
|
|
14
|
+
this.name = "BudgetExceededError";
|
|
15
|
+
this.spentMicros = spentMicros;
|
|
16
|
+
this.limitMicros = limitMicros;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
let warned = false;
|
|
20
|
+
/**
|
|
21
|
+
* What this client has spent, and whether it may spend more.
|
|
22
|
+
*
|
|
23
|
+
* A loop that samples k rollouts across a dataset is the shape of an expensive
|
|
24
|
+
* accident: one bad `k`, one dataset larger than expected, and the bill arrives
|
|
25
|
+
* before anyone notices. The guard is a counter and a comparison, and a cost
|
|
26
|
+
* meter that shipped without one would be embarrassing.
|
|
27
|
+
*
|
|
28
|
+
* Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
|
|
29
|
+
* not gate `report`, which records money the caller already spent elsewhere;
|
|
30
|
+
* counting that would make the ceiling mean two different things at once.
|
|
31
|
+
*/
|
|
32
|
+
export class Budget {
|
|
33
|
+
used = 0;
|
|
34
|
+
limit;
|
|
35
|
+
/**
|
|
36
|
+
* `undefined` means no ceiling and warns once — an unbounded client in a
|
|
37
|
+
* scheduled script is how a surprise bill happens. `null` means the same
|
|
38
|
+
* thing deliberately, and says nothing.
|
|
39
|
+
*/
|
|
40
|
+
constructor(maxCostMicros) {
|
|
41
|
+
this.limit = maxCostMicros ?? Infinity;
|
|
42
|
+
if (maxCostMicros === undefined && !warned) {
|
|
43
|
+
warned = true;
|
|
44
|
+
console.warn("[harness] No maxCostMicros set — this client can spend without limit. " +
|
|
45
|
+
"Pass maxCostMicros, or null to say you meant it.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
spent() {
|
|
49
|
+
return this.used;
|
|
50
|
+
}
|
|
51
|
+
remaining() {
|
|
52
|
+
return this.limit === Infinity ? Infinity : Math.max(0, this.limit - this.used);
|
|
53
|
+
}
|
|
54
|
+
add(costMicros) {
|
|
55
|
+
if (Number.isFinite(costMicros) && costMicros > 0)
|
|
56
|
+
this.used += costMicros;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Throws if the ceiling is already reached.
|
|
60
|
+
*
|
|
61
|
+
* Checked before a request rather than after: stopping once the bill has been
|
|
62
|
+
* incurred is a report, not a limit.
|
|
63
|
+
*/
|
|
64
|
+
assertAffordable() {
|
|
65
|
+
if (this.used >= this.limit)
|
|
66
|
+
throw new BudgetExceededError(this.used, this.limit);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Test seam: lets the once-only warning be exercised more than once. */
|
|
70
|
+
export function resetBudgetWarning() {
|
|
71
|
+
warned = false;
|
|
72
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LRU with a TTL, and staleness reported rather than enforced.
|
|
3
|
+
*
|
|
4
|
+
* `get` returning a stale entry instead of dropping it is what lets `pull`
|
|
5
|
+
* serve immediately and refresh behind the request. The alternative — evict on
|
|
6
|
+
* expiry — puts an HTTP round trip on the critical path of whichever unlucky
|
|
7
|
+
* request arrives one millisecond after the TTL lapses.
|
|
8
|
+
*/
|
|
9
|
+
export interface CacheOptions<T = unknown> {
|
|
10
|
+
/** Entries older than this are stale, not gone. Default 300 (5 minutes). */
|
|
11
|
+
ttlSeconds?: number;
|
|
12
|
+
/** Default 100. */
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
/** Injectable clock, so tests do not sleep. */
|
|
15
|
+
now?: () => number;
|
|
16
|
+
/**
|
|
17
|
+
* How long to leave a key alone after its refresh failed. Default 60.
|
|
18
|
+
*
|
|
19
|
+
* A handle that has stopped resolving is served from cache and retried on a
|
|
20
|
+
* schedule, rather than on every call.
|
|
21
|
+
*/
|
|
22
|
+
retryAfterSeconds?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Called when a background refresh throws.
|
|
25
|
+
*
|
|
26
|
+
* `report` has `onReportError` because silence there loses spend data; the
|
|
27
|
+
* same argument applies here, where silence means serving a wording that can
|
|
28
|
+
* never update. Without it a rename on the dashboard freezes a running
|
|
29
|
+
* service with nothing anywhere saying so.
|
|
30
|
+
*/
|
|
31
|
+
onRefreshError?: (err: unknown, key: string) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Every name a cached value answers to — for a prompt, its id and its slug.
|
|
34
|
+
*
|
|
35
|
+
* The cache is keyed on whatever handle the caller passed, which is why
|
|
36
|
+
* invalidation kept missing: a promotion knows the uuid, production pulled by
|
|
37
|
+
* the slug, and a fetch still in flight carries no payload to connect them.
|
|
38
|
+
* With this, an invalidation is recorded against the prompt rather than
|
|
39
|
+
* against one string, and a fetch is dropped when the thing it fetched was
|
|
40
|
+
* invalidated — not when some unrelated key was.
|
|
41
|
+
*/
|
|
42
|
+
identify?: (value: T) => string[];
|
|
43
|
+
}
|
|
44
|
+
export interface CacheHit<T> {
|
|
45
|
+
value: T;
|
|
46
|
+
stale: boolean;
|
|
47
|
+
ageMs: number;
|
|
48
|
+
}
|
|
49
|
+
export declare class PullCache<T> {
|
|
50
|
+
private readonly store;
|
|
51
|
+
private readonly ttlMs;
|
|
52
|
+
private readonly maxSize;
|
|
53
|
+
private readonly now;
|
|
54
|
+
/** Keys with a refresh in flight, so N concurrent readers cause one fetch. */
|
|
55
|
+
private readonly refreshing;
|
|
56
|
+
/** When a key's refresh last failed, so a dead handle is not retried per call. */
|
|
57
|
+
private readonly failures;
|
|
58
|
+
private readonly retryAfterMs;
|
|
59
|
+
private readonly onRefreshError?;
|
|
60
|
+
private readonly identify?;
|
|
61
|
+
/**
|
|
62
|
+
* A monotonic counter and the tick at which each name was last invalidated.
|
|
63
|
+
*
|
|
64
|
+
* A fetch records the counter when it starts and, when it lands, asks whether
|
|
65
|
+
* anything it turned out to be — its key, its id, its slug — has been
|
|
66
|
+
* invalidated since. That is the whole protocol, and it holds for the cold
|
|
67
|
+
* path, the background refresh, and a handle nobody has seen before, none of
|
|
68
|
+
* which the previous per-key epoch could cover.
|
|
69
|
+
*/
|
|
70
|
+
private tick;
|
|
71
|
+
private readonly invalidatedAt;
|
|
72
|
+
/**
|
|
73
|
+
* The tick of the last `clear()`.
|
|
74
|
+
*
|
|
75
|
+
* `clear` cannot name a fetch it has never seen — a cold one is in neither
|
|
76
|
+
* `store` nor `refreshing` — so it raises a floor instead: anything that
|
|
77
|
+
* started before it is out of date whatever it turns out to be.
|
|
78
|
+
*/
|
|
79
|
+
private clearedAt;
|
|
80
|
+
/** Fetches outstanding, so the log above can be dropped when none remain. */
|
|
81
|
+
private outstanding;
|
|
82
|
+
constructor(opts?: CacheOptions<T>);
|
|
83
|
+
get(key: string): CacheHit<T> | undefined;
|
|
84
|
+
set(key: string, value: T): void;
|
|
85
|
+
/**
|
|
86
|
+
* Runs `refresh` unless one is already in flight for this key.
|
|
87
|
+
*
|
|
88
|
+
* Without the guard, a burst of requests arriving just after the TTL lapses
|
|
89
|
+
* each start their own refresh — the stampede the cache exists to prevent.
|
|
90
|
+
* Failures are swallowed on purpose: a background refresh that throws must not
|
|
91
|
+
* surface in a request that was already answered from cache.
|
|
92
|
+
*/
|
|
93
|
+
refreshInBackground(key: string, refresh: () => Promise<T>): void;
|
|
94
|
+
/** Records a fetch starting, and the tick it must be judged against. */
|
|
95
|
+
beginFetch(): number;
|
|
96
|
+
/** Stores a landed fetch unless what it fetched was invalidated meanwhile. */
|
|
97
|
+
settle(key: string, value: T, startedAt: number): void;
|
|
98
|
+
/** Ends a fetch that threw, and backs the handle off. */
|
|
99
|
+
abandon(key: string): void;
|
|
100
|
+
/** Records that everything under this name is out of date. */
|
|
101
|
+
invalidate(name: string): void;
|
|
102
|
+
/** The log only matters while a fetch could still be judged against it. */
|
|
103
|
+
private forgetLogIfIdle;
|
|
104
|
+
/** Keeps `failures` inside the same bound as the entries themselves. */
|
|
105
|
+
private boundFailures;
|
|
106
|
+
/** Every key whose entry satisfies `match`. */
|
|
107
|
+
keysWhere(match: (value: T) => boolean): string[];
|
|
108
|
+
delete(key: string): void;
|
|
109
|
+
clear(): void;
|
|
110
|
+
get size(): number;
|
|
111
|
+
/**
|
|
112
|
+
* How many keys the cache holds bookkeeping for, entries aside.
|
|
113
|
+
*
|
|
114
|
+
* Exposed for the test that this stays bounded: `epochs` and `failures` are
|
|
115
|
+
* private and grow on paths `size` cannot see, so a test written against
|
|
116
|
+
* `size` passes whether or not they leak — which is what the first one did.
|
|
117
|
+
*/
|
|
118
|
+
get trackedKeys(): number;
|
|
119
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
export class PullCache {
|
|
2
|
+
store = new Map();
|
|
3
|
+
ttlMs;
|
|
4
|
+
maxSize;
|
|
5
|
+
now;
|
|
6
|
+
/** Keys with a refresh in flight, so N concurrent readers cause one fetch. */
|
|
7
|
+
refreshing = new Set();
|
|
8
|
+
/** When a key's refresh last failed, so a dead handle is not retried per call. */
|
|
9
|
+
failures = new Map();
|
|
10
|
+
retryAfterMs;
|
|
11
|
+
onRefreshError;
|
|
12
|
+
identify;
|
|
13
|
+
/**
|
|
14
|
+
* A monotonic counter and the tick at which each name was last invalidated.
|
|
15
|
+
*
|
|
16
|
+
* A fetch records the counter when it starts and, when it lands, asks whether
|
|
17
|
+
* anything it turned out to be — its key, its id, its slug — has been
|
|
18
|
+
* invalidated since. That is the whole protocol, and it holds for the cold
|
|
19
|
+
* path, the background refresh, and a handle nobody has seen before, none of
|
|
20
|
+
* which the previous per-key epoch could cover.
|
|
21
|
+
*/
|
|
22
|
+
tick = 0;
|
|
23
|
+
invalidatedAt = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* The tick of the last `clear()`.
|
|
26
|
+
*
|
|
27
|
+
* `clear` cannot name a fetch it has never seen — a cold one is in neither
|
|
28
|
+
* `store` nor `refreshing` — so it raises a floor instead: anything that
|
|
29
|
+
* started before it is out of date whatever it turns out to be.
|
|
30
|
+
*/
|
|
31
|
+
clearedAt = -1;
|
|
32
|
+
/** Fetches outstanding, so the log above can be dropped when none remain. */
|
|
33
|
+
outstanding = 0;
|
|
34
|
+
constructor(opts = {}) {
|
|
35
|
+
// A TTL of 0 means "always stale", which is a legitimate way to disable
|
|
36
|
+
// caching without a separate flag; only undefined falls back to the default.
|
|
37
|
+
this.ttlMs = (opts.ttlSeconds ?? 300) * 1000;
|
|
38
|
+
this.maxSize = Math.max(1, opts.maxSize ?? 100);
|
|
39
|
+
this.now = opts.now ?? (() => Date.now());
|
|
40
|
+
this.retryAfterMs = (opts.retryAfterSeconds ?? 60) * 1000;
|
|
41
|
+
this.onRefreshError = opts.onRefreshError;
|
|
42
|
+
this.identify = opts.identify;
|
|
43
|
+
}
|
|
44
|
+
get(key) {
|
|
45
|
+
const entry = this.store.get(key);
|
|
46
|
+
if (!entry)
|
|
47
|
+
return undefined;
|
|
48
|
+
// Re-insert to mark most-recently-used: Map iterates in insertion order, so
|
|
49
|
+
// deleting and re-setting moves the key to the end.
|
|
50
|
+
this.store.delete(key);
|
|
51
|
+
this.store.set(key, entry);
|
|
52
|
+
const ageMs = this.now() - entry.at;
|
|
53
|
+
return { value: entry.value, stale: ageMs > this.ttlMs, ageMs };
|
|
54
|
+
}
|
|
55
|
+
set(key, value) {
|
|
56
|
+
this.store.delete(key);
|
|
57
|
+
this.store.set(key, { value, at: this.now() });
|
|
58
|
+
while (this.store.size > this.maxSize) {
|
|
59
|
+
const oldest = this.store.keys().next().value;
|
|
60
|
+
if (oldest === undefined)
|
|
61
|
+
break;
|
|
62
|
+
this.store.delete(oldest);
|
|
63
|
+
// The bookkeeping goes with the entry unless a fetch for it is still
|
|
64
|
+
// running, otherwise these grow forever in a class whose contract is a
|
|
65
|
+
// bounded LRU — one permanent entry per distinct key ever seen.
|
|
66
|
+
if (!this.refreshing.has(oldest))
|
|
67
|
+
this.failures.delete(oldest);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs `refresh` unless one is already in flight for this key.
|
|
72
|
+
*
|
|
73
|
+
* Without the guard, a burst of requests arriving just after the TTL lapses
|
|
74
|
+
* each start their own refresh — the stampede the cache exists to prevent.
|
|
75
|
+
* Failures are swallowed on purpose: a background refresh that throws must not
|
|
76
|
+
* surface in a request that was already answered from cache.
|
|
77
|
+
*/
|
|
78
|
+
refreshInBackground(key, refresh) {
|
|
79
|
+
if (this.refreshing.has(key))
|
|
80
|
+
return;
|
|
81
|
+
// Backed off after a failure. A handle that has stopped resolving — which a
|
|
82
|
+
// rename now makes routine, since it re-mints the slug — otherwise fires a
|
|
83
|
+
// fresh doomed request on every single pull, forever, while serving wording
|
|
84
|
+
// that can never update. The entry stays (stale is better than nothing);
|
|
85
|
+
// what stops is the stampede.
|
|
86
|
+
const failedAt = this.failures.get(key);
|
|
87
|
+
if (failedAt !== undefined && this.now() - failedAt < this.retryAfterMs)
|
|
88
|
+
return;
|
|
89
|
+
this.refreshing.add(key);
|
|
90
|
+
const startedAt = this.beginFetch();
|
|
91
|
+
void refresh()
|
|
92
|
+
.then((value) => this.settle(key, value, startedAt))
|
|
93
|
+
.catch((err) => {
|
|
94
|
+
this.abandon(key);
|
|
95
|
+
// Surfaced rather than swallowed. `report` has onReportError for exactly
|
|
96
|
+
// this reason; without a counterpart here a service can serve a frozen
|
|
97
|
+
// prompt indefinitely with nothing anywhere saying so.
|
|
98
|
+
//
|
|
99
|
+
// Wrapped, because nobody is holding this promise: `pullWithCache` has
|
|
100
|
+
// already returned the stale value, so a callback that throws would be
|
|
101
|
+
// an unhandled rejection — process exit in Node, isolate abort in a
|
|
102
|
+
// Worker.
|
|
103
|
+
try {
|
|
104
|
+
this.onRefreshError?.(err, key);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* a reporter that cannot report is not worth an outage */
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
this.refreshing.delete(key);
|
|
112
|
+
this.forgetLogIfIdle();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Records a fetch starting, and the tick it must be judged against. */
|
|
116
|
+
beginFetch() {
|
|
117
|
+
this.outstanding++;
|
|
118
|
+
return this.tick;
|
|
119
|
+
}
|
|
120
|
+
/** Stores a landed fetch unless what it fetched was invalidated meanwhile. */
|
|
121
|
+
settle(key, value, startedAt) {
|
|
122
|
+
this.outstanding--;
|
|
123
|
+
const names = [key, ...(this.identify?.(value) ?? [])];
|
|
124
|
+
const overtaken = startedAt < this.clearedAt ||
|
|
125
|
+
names.some((n) => (this.invalidatedAt.get(n) ?? -1) > startedAt);
|
|
126
|
+
if (!overtaken) {
|
|
127
|
+
// Only once the value is kept: clearing first meant a fetch that lost the
|
|
128
|
+
// race still wiped a live backoff, turning bounded retry into a retry on
|
|
129
|
+
// every call.
|
|
130
|
+
this.failures.delete(key);
|
|
131
|
+
this.set(key, value);
|
|
132
|
+
}
|
|
133
|
+
this.forgetLogIfIdle();
|
|
134
|
+
}
|
|
135
|
+
/** Ends a fetch that threw, and backs the handle off. */
|
|
136
|
+
abandon(key) {
|
|
137
|
+
this.outstanding--;
|
|
138
|
+
// Recorded here too, not only on the background path. The cold path is the
|
|
139
|
+
// one a fresh isolate takes, so without this a permanently 404ing handle —
|
|
140
|
+
// which a rename now makes routine — issued one upstream request per pull,
|
|
141
|
+
// forever.
|
|
142
|
+
this.failures.set(key, this.now());
|
|
143
|
+
this.boundFailures();
|
|
144
|
+
this.forgetLogIfIdle();
|
|
145
|
+
}
|
|
146
|
+
/** Records that everything under this name is out of date. */
|
|
147
|
+
invalidate(name) {
|
|
148
|
+
this.invalidatedAt.set(name, ++this.tick);
|
|
149
|
+
}
|
|
150
|
+
/** The log only matters while a fetch could still be judged against it. */
|
|
151
|
+
forgetLogIfIdle() {
|
|
152
|
+
if (this.outstanding === 0 && this.refreshing.size === 0)
|
|
153
|
+
this.invalidatedAt.clear();
|
|
154
|
+
}
|
|
155
|
+
/** Keeps `failures` inside the same bound as the entries themselves. */
|
|
156
|
+
boundFailures() {
|
|
157
|
+
while (this.failures.size > this.maxSize) {
|
|
158
|
+
const oldest = this.failures.keys().next().value;
|
|
159
|
+
if (oldest === undefined)
|
|
160
|
+
break;
|
|
161
|
+
this.failures.delete(oldest);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Every key whose entry satisfies `match`. */
|
|
165
|
+
keysWhere(match) {
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const [key, entry] of this.store)
|
|
168
|
+
if (match(entry.value))
|
|
169
|
+
out.push(key);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
delete(key) {
|
|
173
|
+
this.store.delete(key);
|
|
174
|
+
// The backoff goes with it. Otherwise invalidate() could not clear a
|
|
175
|
+
// backoff, and a handle that has demonstrably recovered kept skipping its
|
|
176
|
+
// refresh for the whole retry window — which `retryAfterSeconds` being a
|
|
177
|
+
// public option makes arbitrarily long.
|
|
178
|
+
this.failures.delete(key);
|
|
179
|
+
this.invalidate(key);
|
|
180
|
+
// Nothing outstanding means nothing can be judged against the log, so it is
|
|
181
|
+
// residue — which is what made it grow by one per prompt ever invalidated.
|
|
182
|
+
this.forgetLogIfIdle();
|
|
183
|
+
}
|
|
184
|
+
clear() {
|
|
185
|
+
// Every key the cache has an opinion about, not only the ones with an entry:
|
|
186
|
+
// a cold-isolate fetch is in flight under a key that is not in `store` yet,
|
|
187
|
+
// and bumping only stored keys let it land after the invalidation.
|
|
188
|
+
this.clearedAt = ++this.tick;
|
|
189
|
+
this.store.clear();
|
|
190
|
+
this.failures.clear();
|
|
191
|
+
this.forgetLogIfIdle();
|
|
192
|
+
}
|
|
193
|
+
get size() {
|
|
194
|
+
return this.store.size;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* How many keys the cache holds bookkeeping for, entries aside.
|
|
198
|
+
*
|
|
199
|
+
* Exposed for the test that this stays bounded: `epochs` and `failures` are
|
|
200
|
+
* private and grow on paths `size` cannot see, so a test written against
|
|
201
|
+
* `size` passes whether or not they leak — which is what the first one did.
|
|
202
|
+
*/
|
|
203
|
+
get trackedKeys() {
|
|
204
|
+
return new Set([...this.invalidatedAt.keys(), ...this.failures.keys()]).size;
|
|
205
|
+
}
|
|
206
|
+
}
|