cursedbelt-server 1.1.0 → 2.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/dist/server/bench/assert.d.ts +61 -0
- package/dist/server/bench/assert.js +117 -0
- package/dist/server/bench/budget.d.ts +130 -0
- package/dist/server/bench/budget.js +131 -0
- package/dist/server/bench/cpuBudget.d.ts +45 -0
- package/dist/server/bench/cpuBudget.js +34 -0
- package/dist/server/bench/cpuClock.d.ts +65 -0
- package/dist/server/bench/cpuClock.js +100 -0
- package/dist/server/bench/index.d.ts +40 -0
- package/dist/server/bench/index.js +40 -0
- package/dist/server/bench/recorder.d.ts +70 -0
- package/dist/server/bench/recorder.js +95 -0
- package/dist/server/bench/runBench.d.ts +61 -0
- package/dist/server/bench/runBench.js +61 -0
- package/dist/server/d1/backup.d.ts +110 -0
- package/dist/server/d1/backup.js +128 -0
- package/dist/server/d1/fakeD1.d.ts +41 -0
- package/dist/server/d1/fakeD1.js +185 -0
- package/dist/server/d1/index.d.ts +24 -0
- package/dist/server/d1/index.js +24 -0
- package/dist/server/d1/kysely.d.ts +56 -0
- package/dist/server/d1/kysely.js +138 -0
- package/dist/server/d1/limits.d.ts +56 -0
- package/dist/server/d1/limits.js +96 -0
- package/dist/server/d1/local.d.ts +31 -0
- package/dist/server/d1/local.js +135 -0
- package/dist/server/d1/remote.d.ts +59 -0
- package/dist/server/d1/remote.js +124 -0
- package/dist/server/d1/scheduling.d.ts +113 -0
- package/dist/server/d1/scheduling.js +164 -0
- package/dist/server/d1/types.d.ts +143 -0
- package/dist/server/d1/types.js +80 -0
- package/dist/server/d1/values.d.ts +50 -0
- package/dist/server/d1/values.js +124 -0
- package/dist/server/sync/http.d.ts +20 -3
- package/dist/server/sync/http.js +20 -14
- package/dist/server/sync/index.d.ts +10 -2
- package/dist/server/sync/index.js +9 -1
- package/dist/server/sync/planner.d.ts +38 -8
- package/dist/server/sync/planner.js +32 -8
- package/dist/server/sync/signal.d.ts +161 -0
- package/dist/server/sync/signal.js +348 -0
- package/dist/server/sync/timer.d.ts +63 -19
- package/dist/server/sync/timer.js +104 -45
- package/dist/server/sync/types.d.ts +0 -2
- package/package.json +21 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/noTimerDialsAPeer.spec.ts +469 -0
- package/src/server/bench/assert.ts +192 -0
- package/src/server/bench/budget.spec.ts +126 -0
- package/src/server/bench/budget.ts +207 -0
- package/src/server/bench/cpuBudget.spec.ts +302 -0
- package/src/server/bench/cpuBudget.ts +81 -0
- package/src/server/bench/cpuClock.ts +119 -0
- package/src/server/bench/index.ts +81 -0
- package/src/server/bench/recorder.ts +163 -0
- package/src/server/bench/runBench.ts +110 -0
- package/src/server/d1/backup.spec.ts +121 -0
- package/src/server/d1/backup.ts +186 -0
- package/src/server/d1/fakeD1.ts +193 -0
- package/src/server/d1/index.ts +62 -0
- package/src/server/d1/kysely.spec.ts +145 -0
- package/src/server/d1/kysely.ts +169 -0
- package/src/server/d1/limits.spec.ts +90 -0
- package/src/server/d1/limits.ts +123 -0
- package/src/server/d1/local.ts +173 -0
- package/src/server/d1/remote.ts +182 -0
- package/src/server/d1/sameShape.spec.ts +279 -0
- package/src/server/d1/scheduling.spec.ts +120 -0
- package/src/server/d1/scheduling.ts +210 -0
- package/src/server/d1/types.ts +163 -0
- package/src/server/d1/values.ts +138 -0
- package/src/server/sync/http.ts +31 -16
- package/src/server/sync/index.ts +23 -1
- package/src/server/sync/planner.spec.ts +33 -16
- package/src/server/sync/planner.ts +48 -11
- package/src/server/sync/signal.spec.ts +306 -0
- package/src/server/sync/signal.ts +422 -0
- package/src/server/sync/timer.spec.ts +97 -16
- package/src/server/sync/timer.ts +124 -47
- package/src/server/sync/types.ts +0 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a CPU-millisecond comes from, and how honest it is.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **Every reading carries `proxy`, and it is not decoration.** The task that asked for
|
|
5
|
+
* this module asked for the label in the same breath as the measurement: *"a local number
|
|
6
|
+
* that is called a CPU-ms measurement will be quoted as one."* A number printed without
|
|
7
|
+
* its provenance becomes a fact about Cloudflare's billing the first time somebody pastes
|
|
8
|
+
* it into a summary.
|
|
9
|
+
*
|
|
10
|
+
* Two sources, and they are not interchangeable:
|
|
11
|
+
*
|
|
12
|
+
* · {@link processCpuClock} — `process.cpuUsage()` deltas on Bun/Node. `proxy: true`.
|
|
13
|
+
* · {@link workerCpuClock} — a reader the Worker runtime supplies. `proxy: false`.
|
|
14
|
+
*
|
|
15
|
+
* There is deliberately no built-in Cloudflare reader here. The isolate does not hand a
|
|
16
|
+
* handler its own CPU time; `cpuTime` arrives on the trace event a **tail worker** sees.
|
|
17
|
+
* Inventing an API that does not exist would produce a clock that reads zero for ever and
|
|
18
|
+
* a gate that can never go red — so the platform passes its reader in, or there is none.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* `process.cpuUsage()` deltas — user + system, converted from µs to ms.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **The delta is PROCESS-WIDE, not per-request.** If two requests are in flight the
|
|
24
|
+
* reading for each includes the other's CPU, and any background work (a metrics flush, a
|
|
25
|
+
* GC pause attributed to the interval) lands on whichever handler happened to be open.
|
|
26
|
+
* That is why {@link runCpuBench} drives its cases SERIALLY — a serial driver is what
|
|
27
|
+
* makes this proxy sound enough to gate on. Mounted in production it is advisory: useful
|
|
28
|
+
* for ranking routes, not for a billing claim.
|
|
29
|
+
*/
|
|
30
|
+
export function processCpuClock() {
|
|
31
|
+
const usable = typeof process !== 'undefined' && typeof process.cpuUsage === 'function';
|
|
32
|
+
if (!usable)
|
|
33
|
+
return unavailableCpuClock('process.cpuUsage (absent)');
|
|
34
|
+
return {
|
|
35
|
+
source: 'process.cpuUsage',
|
|
36
|
+
proxy: true,
|
|
37
|
+
available: true,
|
|
38
|
+
start() {
|
|
39
|
+
const before = process.cpuUsage();
|
|
40
|
+
return () => {
|
|
41
|
+
const d = process.cpuUsage(before);
|
|
42
|
+
return (d.user + d.system) / 1000;
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A clock over a CPU-ms reader the runtime supplies. `proxy: false` — this is the real
|
|
49
|
+
* quantity Cloudflare bills, so a report built on it may be quoted as one.
|
|
50
|
+
*
|
|
51
|
+
* @param readCpuMs Returns monotonically non-decreasing CPU-ms for the current invocation.
|
|
52
|
+
*/
|
|
53
|
+
export function workerCpuClock(readCpuMs, source = 'worker-runtime') {
|
|
54
|
+
return {
|
|
55
|
+
source,
|
|
56
|
+
proxy: false,
|
|
57
|
+
available: true,
|
|
58
|
+
start() {
|
|
59
|
+
const before = readCpuMs();
|
|
60
|
+
return () => Math.max(0, readCpuMs() - before);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A clock that cannot measure. Recording through it is a no-op, and the report says so
|
|
66
|
+
* rather than reporting a confident zero.
|
|
67
|
+
*/
|
|
68
|
+
export function unavailableCpuClock(source = 'unavailable') {
|
|
69
|
+
return {
|
|
70
|
+
source,
|
|
71
|
+
proxy: true,
|
|
72
|
+
available: false,
|
|
73
|
+
start: () => () => Number.NaN,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A deterministic clock for tests: each `start()` consumes the next value in `values`,
|
|
78
|
+
* repeating the last one once exhausted. Lets the assertion be proven in both directions
|
|
79
|
+
* without burning real CPU, which is the difference between a test that is fast and
|
|
80
|
+
* reliable and one that is neither.
|
|
81
|
+
*/
|
|
82
|
+
export function fixedCpuClock(values, source = 'fixed') {
|
|
83
|
+
if (values.length === 0)
|
|
84
|
+
throw new Error('fixedCpuClock: needs at least one value');
|
|
85
|
+
let i = 0;
|
|
86
|
+
return {
|
|
87
|
+
source,
|
|
88
|
+
proxy: true,
|
|
89
|
+
available: true,
|
|
90
|
+
start() {
|
|
91
|
+
const v = values[Math.min(i, values.length - 1)];
|
|
92
|
+
i += 1;
|
|
93
|
+
return () => v;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** `workerCpuClock` when the platform supplied a reader, else the local proxy. */
|
|
98
|
+
export function resolveCpuClock(readCpuMs) {
|
|
99
|
+
return readCpuMs ? workerCpuClock(readCpuMs) : processCpuClock();
|
|
100
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/bench` — per-route CPU attribution for a Hono app, a budget the app
|
|
3
|
+
* declares, and an assertion that reddens a build when a route outgrows it.
|
|
4
|
+
*
|
|
5
|
+
* ## The shape of a use
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // routes.budgets.ts — the app declares what each route may cost
|
|
9
|
+
* export const BUDGETS: CpuBudgetConfig = {
|
|
10
|
+
* routes: {
|
|
11
|
+
* 'GET /api/notes/:id': 4,
|
|
12
|
+
* 'POST /api/unlock': { exempt: true, reason: 'argon2id KDF — 332 CPU-ms, measured' },
|
|
13
|
+
* },
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // app.ts
|
|
17
|
+
* export const cpu = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS })
|
|
18
|
+
* app.use('*', cpuBudget({ recorder: cpu }))
|
|
19
|
+
*
|
|
20
|
+
* // cpuBudget.spec.ts — the gate
|
|
21
|
+
* const report = await runCpuBench({ app, recorder: cpu, cases: CASES })
|
|
22
|
+
* assertCpuBudgets(report, { requireDeclared: true })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## 🔴 Two things to carry away
|
|
26
|
+
*
|
|
27
|
+
* **Wall clock is never billed.** Cloudflare's own pricing page says *"No charge or limit
|
|
28
|
+
* for duration"*. `requestLogger` records duration; this records CPU; they are different
|
|
29
|
+
* numbers and only the second one costs money.
|
|
30
|
+
*
|
|
31
|
+
* **A local reading is a PROXY.** `process.cpuUsage()` on this Mac is not Worker CPU-ms.
|
|
32
|
+
* Every report carries `proxy: true` and every formatted report says so, because the
|
|
33
|
+
* alternative is a number that gets quoted as a billing fact.
|
|
34
|
+
*/
|
|
35
|
+
export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert';
|
|
36
|
+
export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
|
|
37
|
+
export { type CpuBudgetOpts, cpuBudget } from './cpuBudget';
|
|
38
|
+
export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock';
|
|
39
|
+
export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder';
|
|
40
|
+
export { BENCH_ORIGIN, type BenchCase, runCpuBench, type RunCpuBenchOpts } from './runBench';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/bench` — per-route CPU attribution for a Hono app, a budget the app
|
|
3
|
+
* declares, and an assertion that reddens a build when a route outgrows it.
|
|
4
|
+
*
|
|
5
|
+
* ## The shape of a use
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // routes.budgets.ts — the app declares what each route may cost
|
|
9
|
+
* export const BUDGETS: CpuBudgetConfig = {
|
|
10
|
+
* routes: {
|
|
11
|
+
* 'GET /api/notes/:id': 4,
|
|
12
|
+
* 'POST /api/unlock': { exempt: true, reason: 'argon2id KDF — 332 CPU-ms, measured' },
|
|
13
|
+
* },
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // app.ts
|
|
17
|
+
* export const cpu = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS })
|
|
18
|
+
* app.use('*', cpuBudget({ recorder: cpu }))
|
|
19
|
+
*
|
|
20
|
+
* // cpuBudget.spec.ts — the gate
|
|
21
|
+
* const report = await runCpuBench({ app, recorder: cpu, cases: CASES })
|
|
22
|
+
* assertCpuBudgets(report, { requireDeclared: true })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## 🔴 Two things to carry away
|
|
26
|
+
*
|
|
27
|
+
* **Wall clock is never billed.** Cloudflare's own pricing page says *"No charge or limit
|
|
28
|
+
* for duration"*. `requestLogger` records duration; this records CPU; they are different
|
|
29
|
+
* numbers and only the second one costs money.
|
|
30
|
+
*
|
|
31
|
+
* **A local reading is a PROXY.** `process.cpuUsage()` on this Mac is not Worker CPU-ms.
|
|
32
|
+
* Every report carries `proxy: true` and every formatted report says so, because the
|
|
33
|
+
* alternative is a number that gets quoted as a billing fact.
|
|
34
|
+
*/
|
|
35
|
+
export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert';
|
|
36
|
+
export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
|
|
37
|
+
export { cpuBudget } from './cpuBudget';
|
|
38
|
+
export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock';
|
|
39
|
+
export { createCpuRecorder, percentile, } from './recorder';
|
|
40
|
+
export { BENCH_ORIGIN, runCpuBench } from './runBench';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-route CPU samples, and the percentiles read off them.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **p99, not a mean.** A mean over a route that is fast 99 times and 400 ms once
|
|
5
|
+
* reports ~4 ms and looks healthy. The number that decides whether a route is safe to
|
|
6
|
+
* make common is its tail, so the mean is reported beside p99 and never instead of it.
|
|
7
|
+
*/
|
|
8
|
+
import { type CpuBudgetConfig } from './budget';
|
|
9
|
+
import type { CpuClock } from './cpuClock';
|
|
10
|
+
/**
|
|
11
|
+
* Nearest-rank percentile over an ASCENDING-sorted array: the smallest value at or below
|
|
12
|
+
* which at least `p`% of samples fall. No interpolation — an interpolated p99 reports a
|
|
13
|
+
* CPU cost no request ever actually paid, which is the wrong direction to be wrong in
|
|
14
|
+
* when the number is a ceiling.
|
|
15
|
+
*/
|
|
16
|
+
export declare function percentile(sortedAsc: readonly number[], p: number): number;
|
|
17
|
+
export interface RouteCpuStats {
|
|
18
|
+
method: string;
|
|
19
|
+
route: string;
|
|
20
|
+
/** `'GET /api/notes/:id'` — how the route is named in a violation message. */
|
|
21
|
+
key: string;
|
|
22
|
+
samples: number;
|
|
23
|
+
/** Samples actually retained (≤ `samples` once the reservoir is full). */
|
|
24
|
+
retained: number;
|
|
25
|
+
mean: number;
|
|
26
|
+
p50: number;
|
|
27
|
+
p95: number;
|
|
28
|
+
p99: number;
|
|
29
|
+
max: number;
|
|
30
|
+
/** Total CPU-ms observed across every sample — what the route costs in aggregate. */
|
|
31
|
+
totalCpuMs: number;
|
|
32
|
+
/** The ceiling in CPU-ms, or `null` when exempt. */
|
|
33
|
+
budgetMs: number | null;
|
|
34
|
+
exemptReason?: string;
|
|
35
|
+
noticeAboveMs?: number;
|
|
36
|
+
/** False when no explicit entry matched and the default was applied. */
|
|
37
|
+
declared: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface CpuBudgetReport {
|
|
40
|
+
/** Provenance of every number below — e.g. `'process.cpuUsage'`. */
|
|
41
|
+
source: string;
|
|
42
|
+
/** 🔴 True ⇒ these are a PROXY for Worker CPU-ms, not a reading of them. */
|
|
43
|
+
proxy: boolean;
|
|
44
|
+
/** False ⇒ nothing could be measured; every stat is NaN and nothing may be concluded. */
|
|
45
|
+
available: boolean;
|
|
46
|
+
routes: RouteCpuStats[];
|
|
47
|
+
}
|
|
48
|
+
export interface CpuRecorderOpts {
|
|
49
|
+
clock: CpuClock;
|
|
50
|
+
config?: CpuBudgetConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Samples retained per route. Beyond this, reservoir sampling keeps the retained set
|
|
53
|
+
* representative of the WHOLE run rather than of its first N requests — truncating
|
|
54
|
+
* would quietly turn a long-lived mount into a measurement of its own warm-up.
|
|
55
|
+
*/
|
|
56
|
+
maxSamplesPerRoute?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Deterministic source of randomness for the reservoir, for tests. Defaults to
|
|
59
|
+
* `Math.random`.
|
|
60
|
+
*/
|
|
61
|
+
random?: () => number;
|
|
62
|
+
}
|
|
63
|
+
export interface CpuRecorder {
|
|
64
|
+
readonly clock: CpuClock;
|
|
65
|
+
readonly config: CpuBudgetConfig;
|
|
66
|
+
record(method: string, route: string, cpuMs: number): void;
|
|
67
|
+
report(): CpuBudgetReport;
|
|
68
|
+
reset(): void;
|
|
69
|
+
}
|
|
70
|
+
export declare function createCpuRecorder(opts: CpuRecorderOpts): CpuRecorder;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-route CPU samples, and the percentiles read off them.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **p99, not a mean.** A mean over a route that is fast 99 times and 400 ms once
|
|
5
|
+
* reports ~4 ms and looks healthy. The number that decides whether a route is safe to
|
|
6
|
+
* make common is its tail, so the mean is reported beside p99 and never instead of it.
|
|
7
|
+
*/
|
|
8
|
+
import { resolveBudget, validateCpuBudgetConfig, } from './budget';
|
|
9
|
+
/**
|
|
10
|
+
* Nearest-rank percentile over an ASCENDING-sorted array: the smallest value at or below
|
|
11
|
+
* which at least `p`% of samples fall. No interpolation — an interpolated p99 reports a
|
|
12
|
+
* CPU cost no request ever actually paid, which is the wrong direction to be wrong in
|
|
13
|
+
* when the number is a ceiling.
|
|
14
|
+
*/
|
|
15
|
+
export function percentile(sortedAsc, p) {
|
|
16
|
+
if (sortedAsc.length === 0)
|
|
17
|
+
return Number.NaN;
|
|
18
|
+
if (p <= 0)
|
|
19
|
+
return sortedAsc[0];
|
|
20
|
+
if (p >= 100)
|
|
21
|
+
return sortedAsc[sortedAsc.length - 1];
|
|
22
|
+
const rank = Math.ceil((p / 100) * sortedAsc.length);
|
|
23
|
+
return sortedAsc[Math.min(sortedAsc.length, Math.max(1, rank)) - 1];
|
|
24
|
+
}
|
|
25
|
+
export function createCpuRecorder(opts) {
|
|
26
|
+
const config = opts.config ?? {};
|
|
27
|
+
validateCpuBudgetConfig(config);
|
|
28
|
+
const cap = opts.maxSamplesPerRoute ?? 10_000;
|
|
29
|
+
if (!(cap > 0))
|
|
30
|
+
throw new Error('createCpuRecorder: maxSamplesPerRoute must be > 0');
|
|
31
|
+
const random = opts.random ?? Math.random;
|
|
32
|
+
let buckets = new Map();
|
|
33
|
+
return {
|
|
34
|
+
clock: opts.clock,
|
|
35
|
+
config,
|
|
36
|
+
record(method, route, cpuMs) {
|
|
37
|
+
// A NaN reading means the clock could not measure. Recording it would poison every
|
|
38
|
+
// percentile on the route, so an unmeasurable request is not a sample.
|
|
39
|
+
if (!Number.isFinite(cpuMs))
|
|
40
|
+
return;
|
|
41
|
+
const key = `${method} ${route}`;
|
|
42
|
+
let b = buckets.get(key);
|
|
43
|
+
if (!b) {
|
|
44
|
+
b = { method, route, seen: 0, samples: [], total: 0 };
|
|
45
|
+
buckets.set(key, b);
|
|
46
|
+
}
|
|
47
|
+
b.seen += 1;
|
|
48
|
+
b.total += cpuMs;
|
|
49
|
+
if (b.samples.length < cap) {
|
|
50
|
+
b.samples.push(cpuMs);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Algorithm R: keep each observed sample with equal probability.
|
|
54
|
+
const j = Math.floor(random() * b.seen);
|
|
55
|
+
if (j < cap)
|
|
56
|
+
b.samples[j] = cpuMs;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
report() {
|
|
60
|
+
const routes = [];
|
|
61
|
+
for (const [key, b] of buckets) {
|
|
62
|
+
const sorted = [...b.samples].sort((x, y) => x - y);
|
|
63
|
+
const resolved = resolveBudget(config, b.method, b.route);
|
|
64
|
+
routes.push({
|
|
65
|
+
method: b.method,
|
|
66
|
+
route: b.route,
|
|
67
|
+
key,
|
|
68
|
+
samples: b.seen,
|
|
69
|
+
retained: sorted.length,
|
|
70
|
+
mean: b.seen > 0 ? b.total / b.seen : Number.NaN,
|
|
71
|
+
p50: percentile(sorted, 50),
|
|
72
|
+
p95: percentile(sorted, 95),
|
|
73
|
+
p99: percentile(sorted, 99),
|
|
74
|
+
max: percentile(sorted, 100),
|
|
75
|
+
totalCpuMs: b.total,
|
|
76
|
+
budgetMs: resolved.cpuMs,
|
|
77
|
+
exemptReason: resolved.exemptReason,
|
|
78
|
+
noticeAboveMs: resolved.noticeAboveMs,
|
|
79
|
+
declared: resolved.declared,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Worst offender first — a report is read from the top.
|
|
83
|
+
routes.sort((a, z) => (z.p99 || 0) - (a.p99 || 0));
|
|
84
|
+
return {
|
|
85
|
+
source: opts.clock.source,
|
|
86
|
+
proxy: opts.clock.proxy,
|
|
87
|
+
available: opts.clock.available,
|
|
88
|
+
routes,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
reset() {
|
|
92
|
+
buckets = new Map();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CpuRecorder } from './recorder';
|
|
2
|
+
import type { CpuBudgetReport } from './recorder';
|
|
3
|
+
/**
|
|
4
|
+
* Drive a Hono app's routes and measure what each costs in CPU.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 **Serial on purpose, and this is the load-bearing decision.** `process.cpuUsage()`
|
|
7
|
+
* deltas are PROCESS-wide (see `cpuClock.ts`), so two requests in flight each absorb the
|
|
8
|
+
* other's CPU and every number is inflated by an amount nobody can subtract afterwards.
|
|
9
|
+
* Running one request at a time is what makes the local proxy sound enough to gate on.
|
|
10
|
+
* There is deliberately no `concurrency` option — it would produce numbers that look
|
|
11
|
+
* finer-grained and are strictly less true.
|
|
12
|
+
*
|
|
13
|
+
* Wall-clock cost of the bench itself is irrelevant; it is a gate, not a load test.
|
|
14
|
+
*/
|
|
15
|
+
/** RFC 2606 reserved TLD — a bench request must never be able to leave the process. */
|
|
16
|
+
export declare const BENCH_ORIGIN = "http://cpu-bench.invalid";
|
|
17
|
+
export interface BenchCase {
|
|
18
|
+
/** Default `'GET'`. */
|
|
19
|
+
method?: string;
|
|
20
|
+
/** Path with a leading slash, e.g. `'/api/notes/abc'`. */
|
|
21
|
+
path: string;
|
|
22
|
+
/** Extra request init — headers, body. `method` above wins over `init.method`. */
|
|
23
|
+
init?: RequestInit;
|
|
24
|
+
/** Overrides the run-wide `iterations` for this case. */
|
|
25
|
+
iterations?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Statuses this case is expected to return. Default: anything `< 400`.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 This guard is the point. A route that 404s or 500s costs almost no CPU, so a
|
|
30
|
+
* bench that does not check the response reports a beautiful number for a handler
|
|
31
|
+
* that never ran — a green budget over a broken route.
|
|
32
|
+
*/
|
|
33
|
+
expectStatus?: number | number[] | ((status: number) => boolean);
|
|
34
|
+
}
|
|
35
|
+
export interface RunCpuBenchOpts {
|
|
36
|
+
/** Anything with Hono's `fetch` shape. */
|
|
37
|
+
app: {
|
|
38
|
+
fetch: (req: Request, env?: unknown, ctx?: unknown) => Response | Promise<Response>;
|
|
39
|
+
};
|
|
40
|
+
/** The recorder the app's `cpuBudget()` middleware writes through. */
|
|
41
|
+
recorder: CpuRecorder;
|
|
42
|
+
cases: BenchCase[];
|
|
43
|
+
/** Measured iterations per case. Default 30 — above `assertCpuBudgets`' 20-sample floor. */
|
|
44
|
+
iterations?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Unmeasured iterations per case, run first and then DISCARDED.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 Without this the bench measures JIT warm-up. A first call through a cold handler
|
|
49
|
+
* can cost an order of magnitude more than its steady state, and with 30 samples one
|
|
50
|
+
* cold call lands squarely in the p99 — so the route that fails is whichever one the
|
|
51
|
+
* bench happened to touch first. Default 5.
|
|
52
|
+
*/
|
|
53
|
+
warmup?: number;
|
|
54
|
+
/** Origin for the synthesized requests. Default {@link BENCH_ORIGIN}. */
|
|
55
|
+
origin?: string;
|
|
56
|
+
/** Passed through to `app.fetch` as the Worker `env`. */
|
|
57
|
+
env?: unknown;
|
|
58
|
+
/** Passed through to `app.fetch` as the Worker execution context. */
|
|
59
|
+
ctx?: unknown;
|
|
60
|
+
}
|
|
61
|
+
export declare function runCpuBench(opts: RunCpuBenchOpts): Promise<CpuBudgetReport>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive a Hono app's routes and measure what each costs in CPU.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **Serial on purpose, and this is the load-bearing decision.** `process.cpuUsage()`
|
|
5
|
+
* deltas are PROCESS-wide (see `cpuClock.ts`), so two requests in flight each absorb the
|
|
6
|
+
* other's CPU and every number is inflated by an amount nobody can subtract afterwards.
|
|
7
|
+
* Running one request at a time is what makes the local proxy sound enough to gate on.
|
|
8
|
+
* There is deliberately no `concurrency` option — it would produce numbers that look
|
|
9
|
+
* finer-grained and are strictly less true.
|
|
10
|
+
*
|
|
11
|
+
* Wall-clock cost of the bench itself is irrelevant; it is a gate, not a load test.
|
|
12
|
+
*/
|
|
13
|
+
/** RFC 2606 reserved TLD — a bench request must never be able to leave the process. */
|
|
14
|
+
export const BENCH_ORIGIN = 'http://cpu-bench.invalid';
|
|
15
|
+
function statusAllowed(c, status) {
|
|
16
|
+
const expect = c.expectStatus;
|
|
17
|
+
if (expect === undefined)
|
|
18
|
+
return status < 400;
|
|
19
|
+
if (typeof expect === 'function')
|
|
20
|
+
return expect(status);
|
|
21
|
+
if (Array.isArray(expect))
|
|
22
|
+
return expect.includes(status);
|
|
23
|
+
return expect === status;
|
|
24
|
+
}
|
|
25
|
+
function describe(c) {
|
|
26
|
+
return `${(c.method ?? 'GET').toUpperCase()} ${c.path}`;
|
|
27
|
+
}
|
|
28
|
+
async function fire(opts, c) {
|
|
29
|
+
const method = (c.method ?? c.init?.method ?? 'GET').toUpperCase();
|
|
30
|
+
const req = new Request(`${opts.origin ?? BENCH_ORIGIN}${c.path}`, { ...c.init, method });
|
|
31
|
+
const res = await opts.app.fetch(req, opts.env, opts.ctx);
|
|
32
|
+
if (!statusAllowed(c, res.status)) {
|
|
33
|
+
const body = await res.text().catch(() => '');
|
|
34
|
+
throw new Error(`cpu-bench: ${describe(c)} returned ${res.status}, which this case does not expect. ` +
|
|
35
|
+
'A route that errors costs no CPU, so measuring it would report a budget it never ' +
|
|
36
|
+
`met. Fix the case or set expectStatus.${body ? ` Body: ${body.slice(0, 200)}` : ''}`);
|
|
37
|
+
}
|
|
38
|
+
// Drain the body so a streamed response's work is actually done before the span ends.
|
|
39
|
+
if (res.body && !res.bodyUsed)
|
|
40
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
41
|
+
}
|
|
42
|
+
export async function runCpuBench(opts) {
|
|
43
|
+
if (opts.cases.length === 0)
|
|
44
|
+
throw new Error('runCpuBench: no cases given');
|
|
45
|
+
const iterations = opts.iterations ?? 30;
|
|
46
|
+
const warmup = opts.warmup ?? 5;
|
|
47
|
+
if (!(iterations > 0))
|
|
48
|
+
throw new Error('runCpuBench: iterations must be > 0');
|
|
49
|
+
for (const c of opts.cases) {
|
|
50
|
+
for (let i = 0; i < warmup; i += 1)
|
|
51
|
+
await fire(opts, c);
|
|
52
|
+
}
|
|
53
|
+
// 🔴 Everything above was warm-up. Discard it — measuring it is the bug this guards.
|
|
54
|
+
opts.recorder.reset();
|
|
55
|
+
for (const c of opts.cases) {
|
|
56
|
+
const n = c.iterations ?? iterations;
|
|
57
|
+
for (let i = 0; i < n; i += 1)
|
|
58
|
+
await fire(opts, c);
|
|
59
|
+
}
|
|
60
|
+
return opts.recorder.report();
|
|
61
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backup seam — `PRAGMA wal_checkpoint(TRUNCATE)` locally, Time Travel remotely.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 BOTH, not one
|
|
5
|
+
*
|
|
6
|
+
* D1 has no WAL a caller can checkpoint, and 30 days of free point-in-time restore is
|
|
7
|
+
* strictly better than the file copy it replaces. It is tempting to read that as "the
|
|
8
|
+
* checkpoint goes away". It does not: `PRAGMA wal_checkpoint(TRUNCATE)` is in the backup
|
|
9
|
+
* path of every app in this fleet and it is load-bearing while any of them is still
|
|
10
|
+
* Mac-hosted. Measured previously, `family.sqlite` was **2.2 MB of principal against a
|
|
11
|
+
* 2.1 MB WAL** — an un-checkpointed copy is half a database, and `VACUUM INTO` merging
|
|
12
|
+
* the WAL is the only reason the existing `../sqlite/backup.ts` is safe.
|
|
13
|
+
*
|
|
14
|
+
* So the local implementation keeps checkpointing, the remote one records a restore
|
|
15
|
+
* coordinate, and both answer the same interface. An app's backup job stops caring which
|
|
16
|
+
* side it is on — which is the property that lets the job move before the database does.
|
|
17
|
+
*
|
|
18
|
+
* ## The remote side does not "take" a backup, and that is not a gap
|
|
19
|
+
*
|
|
20
|
+
* Time Travel is automatic and continuous: D1 retains 30 days and restores to any
|
|
21
|
+
* timestamp or bookmark within it. There is nothing to trigger, so
|
|
22
|
+
* {@link createTimeTravelBackup} records the coordinate rather than inventing an API call
|
|
23
|
+
* that does not exist. The coordinate IS the backup — `wrangler d1 time-travel restore`
|
|
24
|
+
* takes a timestamp.
|
|
25
|
+
*
|
|
26
|
+
* 🔴 **`wrangler d1 export` is the off-Cloudflare leg, and a Worker cannot run it.** A
|
|
27
|
+
* Worker has no shell. That leg belongs to a Cron Trigger on this Mac or in CI, which is
|
|
28
|
+
* why {@link BackupPoint.exportCommand} hands back the command rather than running it —
|
|
29
|
+
* the same split as the binary server, where the bytes and the thing that copies them are
|
|
30
|
+
* deliberately not the same process.
|
|
31
|
+
*/
|
|
32
|
+
import type { Database } from 'bun:sqlite';
|
|
33
|
+
/** A restore coordinate — a file on this Mac, or a point in D1's retention window. */
|
|
34
|
+
export interface BackupPoint {
|
|
35
|
+
kind: 'file' | 'time-travel';
|
|
36
|
+
/** A filesystem path for `file`; an ISO-8601 timestamp for `time-travel`. */
|
|
37
|
+
ref: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
/** Bytes on disk, or `null` where the platform does not tell us. */
|
|
40
|
+
bytes: number | null;
|
|
41
|
+
/** The exact command that restores this point. Printed in the job log, not executed. */
|
|
42
|
+
restoreCommand: string;
|
|
43
|
+
/** For `time-travel`, the command that pulls a copy OFF Cloudflare. `null` locally. */
|
|
44
|
+
exportCommand: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface DatabaseBackup {
|
|
47
|
+
/** Capture a restore point now, and describe it. */
|
|
48
|
+
capture(): Promise<BackupPoint>;
|
|
49
|
+
/** Whether this side needs a periodic capture at all. Time Travel does not. */
|
|
50
|
+
readonly needsPeriodicCapture: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface LocalBackupOpts {
|
|
53
|
+
/** The live handle — checkpointed before the snapshot is taken. */
|
|
54
|
+
db: Database;
|
|
55
|
+
/** Path of the database file being backed up. */
|
|
56
|
+
sourcePath: string;
|
|
57
|
+
/** Directory the snapshot is written into. Created if missing. */
|
|
58
|
+
destDir: string;
|
|
59
|
+
/** Override the snapshot's file name. Defaults to `<name>-<ISO>.sqlite`. */
|
|
60
|
+
nameFor?: (now: Date) => string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Force the WAL back into the principal database and truncate the log file.
|
|
64
|
+
*
|
|
65
|
+
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
66
|
+
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
67
|
+
*
|
|
68
|
+
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
69
|
+
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
70
|
+
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
71
|
+
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
72
|
+
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
73
|
+
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
74
|
+
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function checkpointWal(db: Database): {
|
|
77
|
+
busy: boolean;
|
|
78
|
+
logPages: number;
|
|
79
|
+
checkpointed: number;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
83
|
+
*
|
|
84
|
+
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
85
|
+
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
86
|
+
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
87
|
+
* came to carry a 2.1 MB WAL.
|
|
88
|
+
*/
|
|
89
|
+
export declare function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup;
|
|
90
|
+
export interface TimeTravelOpts {
|
|
91
|
+
/** The database name as `wrangler` knows it — what the restore command needs. */
|
|
92
|
+
databaseName: string;
|
|
93
|
+
/** Where an export should be written, for the off-Cloudflare leg. */
|
|
94
|
+
exportPath?: string;
|
|
95
|
+
/** Injected for the test; defaults to the real clock. */
|
|
96
|
+
now?: () => Date;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The D1 implementation: record the coordinate, because retention is automatic.
|
|
100
|
+
*
|
|
101
|
+
* 🔴 It reports `needsPeriodicCapture: false`, and a scheduler must honour that rather
|
|
102
|
+
* than calling `capture()` on a timer — a nightly no-op job that logs "backup complete"
|
|
103
|
+
* is worse than no job, because it reads as evidence.
|
|
104
|
+
*/
|
|
105
|
+
export declare function createTimeTravelBackup(opts: TimeTravelOpts): DatabaseBackup;
|
|
106
|
+
/**
|
|
107
|
+
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
108
|
+
* the same on both sides.
|
|
109
|
+
*/
|
|
110
|
+
export declare function backupFor(flavor: 'local' | 'd1', local: () => LocalBackupOpts, remote: () => TimeTravelOpts): DatabaseBackup;
|