cursedbelt-server 4.9.0 โ 4.11.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/cpuClock.d.ts +4 -0
- package/dist/server/bench/cpuClock.js +57 -3
- package/dist/server/bench/runBench.d.ts +27 -0
- package/dist/server/bench/runBench.js +42 -2
- package/dist/server/d1/index.d.ts +1 -1
- package/dist/server/d1/index.js +1 -1
- package/dist/server/d1/invocation.d.ts +72 -0
- package/dist/server/d1/invocation.js +65 -0
- package/package.json +1 -1
- package/src/nodeBuiltinsAreProbedByCalling.spec.ts +233 -0
- package/src/publicSurface.spec.ts +88 -0
- package/src/server/bench/cpuBudget.spec.ts +191 -1
- package/src/server/bench/cpuClock.spec.ts +141 -0
- package/src/server/bench/cpuClock.ts +55 -2
- package/src/server/bench/runBench.ts +73 -2
- package/src/server/d1/index.ts +1 -1
- package/src/server/d1/invocation.spec.ts +65 -1
- package/src/server/d1/invocation.ts +86 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { run } from '../scripts/publicSurface';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The public-surface ratchet, wired into `bun run verify` at its cheapest correct
|
|
6
|
+
* home: `verify` ends in `bun test src`, so this needs no new script and cannot be a
|
|
7
|
+
* script somebody forgot to chain.
|
|
8
|
+
*
|
|
9
|
+
* WHY it exists, WHAT a symbol count includes and excludes, why the dependency list
|
|
10
|
+
* and the used-vs-exported ratio are deliberately NOT computed here, and what
|
|
11
|
+
* `--prune` may and may not do are all in the header of `scripts/publicSurface.ts` โ
|
|
12
|
+
* one check, one baseline (`publicSurface.baseline`), one place that explains itself.
|
|
13
|
+
* This file is only the wiring.
|
|
14
|
+
*
|
|
15
|
+
* ๐ด Verified failing, 2026-09-19, before it was trusted โ every branch, not just the
|
|
16
|
+
* easy one. Each probe was applied, observed, and removed again:
|
|
17
|
+
*
|
|
18
|
+
* ยท `export const ratchetProbe = 1` appended to `src/server/errors.ts`
|
|
19
|
+
* โ this file's "ceiling" test red โ `3 pass, 1 fail`, "exports no more symbols per
|
|
20
|
+
* subpath than the baseline allows" โ and the CLI:
|
|
21
|
+
* "1 subpath(s) that gained exported symbols ยท ./errors โ baseline allows 14,
|
|
22
|
+
* exports 15 now"
|
|
23
|
+
* ยท the same line appended to `src/server/guard/revocationStore.ts`, which is where the
|
|
24
|
+
* transitive `export *` walk is actually visible: `./guard/revocations` points at that
|
|
25
|
+
* file and `./guard`'s barrel does `export * from './revocationStore'`
|
|
26
|
+
* โ "2 subpath(s) that gained exported symbols ยท ./guard โ baseline allows 25,
|
|
27
|
+
* exports 26 now ยท ./guard/revocations โ baseline allows 7, exports 8 now".
|
|
28
|
+
* ๐ด TWO from one line, which is the walk the header promises, seen working. Note
|
|
29
|
+
* which probe does NOT do this: `.` is a barrel of 219 NAMED re-exports, so a new
|
|
30
|
+
* symbol in `errors.ts` does not reach it. A probe chosen in the wrong module
|
|
31
|
+
* would have "proved" the transitive walk by never exercising it.
|
|
32
|
+
* ยท a `"./probe"` subpath added to `package.json` `exports`, pointed at
|
|
33
|
+
* `./src/server/errors.ts`
|
|
34
|
+
* โ "1 export subpath(s) the baseline does not know ยท ./probe โ NEW subpath,
|
|
35
|
+
* 14 exported symbol(s)"
|
|
36
|
+
* ยท `VALIDATION_STATUS` removed from the re-export list in `src/server/errors.ts`, no prune
|
|
37
|
+
* โ "1 baseline entr(y|ies) that no longer match `exports` ยท ./errors โ baseline
|
|
38
|
+
* says 14, exports 13"
|
|
39
|
+
* ยท a `5 ./gone` line added to the baseline by hand
|
|
40
|
+
* โ same failure, "./gone โ baseline says 5, the subpath is gone". An entry
|
|
41
|
+
* matching nothing is the half that stops a deleted module leaving an allowance.
|
|
42
|
+
* ยท `--prune` run against the grown surface
|
|
43
|
+
* โ "refuses while the surface has GROWN โ it may only lower the baseline", exit 1
|
|
44
|
+
* ยท `--prune` run against the shrunk surface
|
|
45
|
+
* โ "pruned to 34 subpath(s), 0 removed, 990 symbols", exit 0, and `14 ./errors`
|
|
46
|
+
* became `13 ./errors` in the file โ the burn-down path
|
|
47
|
+
* ยท `--seed` run over the seeded baseline
|
|
48
|
+
* โ "refuses: โฆ already records 34 subpath(s)", exit 1
|
|
49
|
+
*
|
|
50
|
+
* Every probe was reverted with `git checkout -- <path>`; the tree afterwards held only
|
|
51
|
+
* this file, `scripts/publicSurface.ts` and `publicSurface.baseline`.
|
|
52
|
+
*
|
|
53
|
+
* A ratchet nobody has seen red is a ratchet nobody knows is wired up.
|
|
54
|
+
*/
|
|
55
|
+
const result = await run();
|
|
56
|
+
|
|
57
|
+
describe('public surface', () => {
|
|
58
|
+
it('has measured a real surface', () => {
|
|
59
|
+
// Guards the three assertions below: if `exports` were ever read as empty, every
|
|
60
|
+
// one of them passes vacuously and the ratchet quietly dies.
|
|
61
|
+
// ๐ด 20, not 34: a vacuity guard set at the real surface fails for the one reason
|
|
62
|
+
// a vacuity guard must never fail โ the surface being healthy and simply smaller.
|
|
63
|
+
expect(result.surface.counts.size).toBeGreaterThan(20);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('publishes no export subpath the baseline does not know', () => {
|
|
67
|
+
expect(
|
|
68
|
+
result.fresh,
|
|
69
|
+
`new public subpath(s) โ a permanent promise. Add the line to publicSurface.baseline and say why:\n ${result.fresh.join('\n ')}`,
|
|
70
|
+
).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('exports no more symbols per subpath than the baseline allows', () => {
|
|
74
|
+
expect(
|
|
75
|
+
result.grown,
|
|
76
|
+
`a baseline entry is a ceiling, not an amnesty:\n ${result.grown.join('\n ')}`,
|
|
77
|
+
).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('has no baseline entry that outlived the surface it recorded', () => {
|
|
81
|
+
// The half that makes it a RATCHET: a deleted module may not leave an allowance
|
|
82
|
+
// behind for the next one to grow into.
|
|
83
|
+
expect(
|
|
84
|
+
result.stale,
|
|
85
|
+
`surface came off and the ratchet was not tightened โ run \`bun scripts/publicSurface.ts --prune\`:\n ${result.stale.join('\n ')}`,
|
|
86
|
+
).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
workerCpuClock,
|
|
11
11
|
} from './cpuClock';
|
|
12
12
|
import { createCpuRecorder } from './recorder';
|
|
13
|
-
import { runCpuBench } from './runBench';
|
|
13
|
+
import { BENCH_ORIGIN, runCpuBench } from './runBench';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* ๐ด **The assertion must fire in BOTH directions or it is decoration.** A gate that can
|
|
@@ -306,6 +306,196 @@ describe('the bench runner', () => {
|
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* ๐ด **A destructive route benched without a fixture rebuild reports a number for the wrong
|
|
311
|
+
* reason**, and `expectStatus` cannot see it: the second purge legitimately answers 200 with
|
|
312
|
+
* `{ deleted: 0 }`. One call runs against a populated library and the next twenty-nine
|
|
313
|
+
* against an empty one, so the p99 describes the empty case and the budget it satisfies
|
|
314
|
+
* means nothing. The first block below IS that hole, measured; `beforeEach` is what closes
|
|
315
|
+
* it; the last two are the ways closing it could itself become a lie.
|
|
316
|
+
*
|
|
317
|
+
* Filed 2026-09-19 by the `collections` port โ the first app to mount this harness โ which
|
|
318
|
+
* had to leave `POST /api/duplicates/rescan` and `POST /api/recycle/purge`, the only two
|
|
319
|
+
* routes in it whose cost is O(catalogue), declared but unbenched for exactly this reason.
|
|
320
|
+
*/
|
|
321
|
+
describe('a destructive case โ the fixture rebuild between iterations', () => {
|
|
322
|
+
/** ~4 CPU-ms per item held, ~0 CPU-ms when the library is empty. */
|
|
323
|
+
const PURGE_ITEM_MS = 4;
|
|
324
|
+
const PURGE_ITEMS = 3;
|
|
325
|
+
const POPULATED_MS = PURGE_ITEM_MS * PURGE_ITEMS;
|
|
326
|
+
|
|
327
|
+
/** A route whose cost is O(library) and which EMPTIES the library โ a purge, in miniature. */
|
|
328
|
+
function buildDestructiveApp() {
|
|
329
|
+
const recorder = createCpuRecorder({
|
|
330
|
+
clock: processCpuClock(),
|
|
331
|
+
config: { routes: { 'POST /api/purge': 60 } },
|
|
332
|
+
});
|
|
333
|
+
const app = new Hono();
|
|
334
|
+
app.use('*', cpuBudget({ recorder }));
|
|
335
|
+
let library: string[] = [];
|
|
336
|
+
/** How many items each call actually found to purge โ the fixture's state, per iteration. */
|
|
337
|
+
const purged: number[] = [];
|
|
338
|
+
app.post('/api/purge', (c) => {
|
|
339
|
+
let acc = 0;
|
|
340
|
+
for (const item of library) acc += burnCpu(PURGE_ITEM_MS) + item.length;
|
|
341
|
+
purged.push(library.length);
|
|
342
|
+
library = [];
|
|
343
|
+
return c.json({ deleted: purged[purged.length - 1], acc });
|
|
344
|
+
});
|
|
345
|
+
const seed = () => {
|
|
346
|
+
library = Array.from({ length: PURGE_ITEMS }, (_, i) => `item-${i}`);
|
|
347
|
+
};
|
|
348
|
+
return { app, recorder, seed, purged };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
it('WITHOUT the hook, measures an empty library from the second call on โ the hole itself', async () => {
|
|
352
|
+
const d = buildDestructiveApp();
|
|
353
|
+
d.seed();
|
|
354
|
+
// warmup 0 so the one populated call is a measured sample rather than a discarded one โ
|
|
355
|
+
// otherwise the trap would be invisible here for a second, different reason.
|
|
356
|
+
const report = await runCpuBench({
|
|
357
|
+
app: d.app,
|
|
358
|
+
recorder: d.recorder,
|
|
359
|
+
cases: [{ method: 'POST', path: '/api/purge' }],
|
|
360
|
+
iterations: 100,
|
|
361
|
+
warmup: 0,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// One real purge, ninety-nine against nothing.
|
|
365
|
+
expect(d.purged[0]).toBe(PURGE_ITEMS);
|
|
366
|
+
expect(d.purged.slice(1).every((n) => n === 0)).toBe(true);
|
|
367
|
+
|
|
368
|
+
const row = report.routes[0];
|
|
369
|
+
expect(row.max).toBeGreaterThan(POPULATED_MS * 0.5); // the route really does cost this
|
|
370
|
+
expect(row.p99).toBeLessThan(2); // โฆand the number the budget is read off says it does not
|
|
371
|
+
expect(assertCpuBudgets(report)).toEqual([]); // green, and meaningless. That is the bug.
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('WITH the hook, measures the populated library on every single iteration', async () => {
|
|
375
|
+
const d = buildDestructiveApp();
|
|
376
|
+
const seen: number[] = [];
|
|
377
|
+
const report = await runCpuBench({
|
|
378
|
+
app: d.app,
|
|
379
|
+
recorder: d.recorder,
|
|
380
|
+
cases: [
|
|
381
|
+
{
|
|
382
|
+
method: 'POST',
|
|
383
|
+
path: '/api/purge',
|
|
384
|
+
beforeEach: (c, iteration) => {
|
|
385
|
+
expect(c.path).toBe('/api/purge');
|
|
386
|
+
seen.push(iteration);
|
|
387
|
+
d.seed();
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
iterations: 20,
|
|
392
|
+
warmup: 2,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ๐ด EVERY call found a full library, warm-up included โ not just the first.
|
|
396
|
+
expect(d.purged).toHaveLength(22);
|
|
397
|
+
expect(d.purged.every((n) => n === PURGE_ITEMS)).toBe(true);
|
|
398
|
+
// The iteration counter is 0-based and monotonic across both passes, so a hook that
|
|
399
|
+
// seeds ids from it cannot collide between the warm-up and the measured run.
|
|
400
|
+
expect(seen).toEqual(Array.from({ length: 22 }, (_, i) => i));
|
|
401
|
+
|
|
402
|
+
const row = report.routes[0];
|
|
403
|
+
expect(row.samples).toBe(20);
|
|
404
|
+
// The p50 โ not just the max โ now carries the real cost. Before the hook it was ~0.
|
|
405
|
+
expect(row.p50).toBeGreaterThan(POPULATED_MS * 0.5);
|
|
406
|
+
expect(row.p99).toBeGreaterThan(POPULATED_MS * 0.5);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('is PER CASE โ a cheap read case beside a destructive one pays nothing for it', async () => {
|
|
410
|
+
const d = buildDestructiveApp();
|
|
411
|
+
d.app.get('/api/cheap', (c) => c.json({ ok: true }));
|
|
412
|
+
let rebuilds = 0;
|
|
413
|
+
const report = await runCpuBench({
|
|
414
|
+
app: d.app,
|
|
415
|
+
recorder: d.recorder,
|
|
416
|
+
cases: [
|
|
417
|
+
{ path: '/api/cheap' },
|
|
418
|
+
{
|
|
419
|
+
method: 'POST',
|
|
420
|
+
path: '/api/purge',
|
|
421
|
+
beforeEach: () => {
|
|
422
|
+
rebuilds += 1;
|
|
423
|
+
d.seed();
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
iterations: 20,
|
|
428
|
+
warmup: 1,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// 21 for the destructive case and not one for the read โ the read case never called it.
|
|
432
|
+
expect(rebuilds).toBe(21);
|
|
433
|
+
expect(d.purged).toHaveLength(21);
|
|
434
|
+
const cheap = report.routes.find((r) => r.route === '/api/cheap');
|
|
435
|
+
const purge = report.routes.find((r) => r.route === '/api/purge');
|
|
436
|
+
expect(cheap?.p99).toBeLessThan(2);
|
|
437
|
+
expect(purge?.p99).toBeGreaterThan(POPULATED_MS * 0.5);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
/*
|
|
441
|
+
* ๐ด The hook's own CPU must not land in the histogram. It is excluded by ORDERING โ the
|
|
442
|
+
* recorder is written through by the middleware inside `app.fetch`, and the hook runs
|
|
443
|
+
* outside it โ and ordering is an accident, so it is asserted here rather than relied on.
|
|
444
|
+
* A fixture rebuild that cost 200 ms and got attributed to the route would make this
|
|
445
|
+
* feature a worse lie than the hole it closes.
|
|
446
|
+
*/
|
|
447
|
+
it("๐ด does NOT record the hook's own CPU, however expensive the rebuild is", async () => {
|
|
448
|
+
const { app, recorder } = buildApp({ routes: { 'GET /api/cheap': 5 } });
|
|
449
|
+
let rebuilds = 0;
|
|
450
|
+
const report = await runCpuBench({
|
|
451
|
+
app,
|
|
452
|
+
recorder,
|
|
453
|
+
cases: [
|
|
454
|
+
{
|
|
455
|
+
path: '/api/cheap',
|
|
456
|
+
beforeEach: () => {
|
|
457
|
+
rebuilds += 1;
|
|
458
|
+
burnCpu(SLOW_MS); // 30 CPU-ms of rebuild, ~3ร the default budget, every iteration
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
iterations: 20,
|
|
463
|
+
warmup: 2,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
expect(rebuilds).toBe(22); // 22 ร 30 CPU-ms was genuinely burned during this runโฆ
|
|
467
|
+
const row = report.routes[0];
|
|
468
|
+
expect(row.samples).toBe(20);
|
|
469
|
+
// โฆand none of it is in these numbers. Even the MAX stays far under one rebuild.
|
|
470
|
+
expect(row.max).toBeLessThan(SLOW_MS / 3);
|
|
471
|
+
expect(row.p99).toBeLessThan(5);
|
|
472
|
+
expect(assertCpuBudgets(report)).toEqual([]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('REFUSES a hook that rebuilds the fixture through the app under bench', async () => {
|
|
476
|
+
// The obvious way to re-seed a library is to drive the app that has the routes. Those
|
|
477
|
+
// requests go through `cpuBudget()` like any other, so the rebuild would land in the
|
|
478
|
+
// report as real samples on real routes โ the same lie, arriving from the other side.
|
|
479
|
+
const { app, recorder } = buildApp({});
|
|
480
|
+
await expect(
|
|
481
|
+
runCpuBench({
|
|
482
|
+
app,
|
|
483
|
+
recorder,
|
|
484
|
+
cases: [
|
|
485
|
+
{
|
|
486
|
+
path: '/api/cheap',
|
|
487
|
+
beforeEach: async () => {
|
|
488
|
+
await app.request(`${BENCH_ORIGIN}/api/cheap`);
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
iterations: 5,
|
|
493
|
+
warmup: 0,
|
|
494
|
+
}),
|
|
495
|
+
).rejects.toThrow(/beforeEach drove 1 request\(s\) through the app under bench/);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
309
499
|
/**
|
|
310
500
|
* ๐ด **The third direction: a gate that CANNOT go red.**
|
|
311
501
|
*
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { cpuBudget } from './cpuBudget';
|
|
4
|
+
import { processCpuClock } from './cpuClock';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ๐ด **The runtime this file exists for is the one `bun test` is not.**
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-09-19 on the first real deploy of `apps/collections`:
|
|
10
|
+
* `https://collections.curtwphillips.workers.dev/healthz` answered
|
|
11
|
+
* `500 Internal Server Error` on every path, with 843 green tests behind it. Under
|
|
12
|
+
* `nodejs_compat`, workerd PROVIDES `process.cpuUsage` and throws when it is called โ
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* Error [ERR_METHOD_NOT_IMPLEMENTED]: The process.cpuUsage method is not implemented
|
|
16
|
+
* at process.cpuUsage (node-internal:public_process:235:11)
|
|
17
|
+
* at Object.start (index.js:2772:30) <- cpuBudget's middleware
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* โ so the old `typeof process.cpuUsage === 'function'` probe said *available*, and
|
|
21
|
+
* `cpuBudget()`, which is mounted FIRST by design, threw in front of the whole app.
|
|
22
|
+
*
|
|
23
|
+
* Every case below stubs a THROWING method, not a missing one; a missing one was always
|
|
24
|
+
* handled and was never the bug.
|
|
25
|
+
*
|
|
26
|
+
* ๐ด Proven red, 2026-09-19, by putting the `typeof` probe back: **`2 pass, 4 fail`**. The
|
|
27
|
+
* second case reproduced the outage exactly, stack and all โ
|
|
28
|
+
*
|
|
29
|
+
* ```
|
|
30
|
+
* error: The process.cpuUsage method is not implemented code: "ERR_METHOD_NOT_IMPLEMENTED"
|
|
31
|
+
* at start (src/server/bench/cpuClock.ts:59:30)
|
|
32
|
+
* at <anonymous> (src/server/bench/cpuBudget.ts:54:32)
|
|
33
|
+
* expect(res.status).toBe(200) Expected: 200 Received: 500
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* The two that passed against the old code are the two that must: `(absent)`, which the
|
|
37
|
+
* `typeof` probe did answer correctly, and the real-runtime direction.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const REAL_CPU_USAGE = process.cpuUsage;
|
|
41
|
+
|
|
42
|
+
/** Replace `process.cpuUsage` for one test. Restored by the `afterEach` below, always. */
|
|
43
|
+
function stubCpuUsage(fn: unknown): void {
|
|
44
|
+
(process as unknown as { cpuUsage: unknown }).cpuUsage = fn;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** workerd's shape: present, callable, and it throws. */
|
|
48
|
+
function methodNotImplemented(): never {
|
|
49
|
+
const err = new Error('The process.cpuUsage method is not implemented');
|
|
50
|
+
(err as Error & { code?: string }).code = 'ERR_METHOD_NOT_IMPLEMENTED';
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
stubCpuUsage(REAL_CPU_USAGE);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('processCpuClock() under a runtime that stubs the method and throws', () => {
|
|
59
|
+
it('returns an UNAVAILABLE clock rather than one that throws on first use', () => {
|
|
60
|
+
stubCpuUsage(methodNotImplemented);
|
|
61
|
+
|
|
62
|
+
const clock = processCpuClock();
|
|
63
|
+
expect(clock.available).toBe(false);
|
|
64
|
+
// The label says which of the two failure shapes this was: it was there, and it threw.
|
|
65
|
+
expect(clock.source).toBe('process.cpuUsage (throws)');
|
|
66
|
+
// ๐ด And the span is a NaN, not a throw โ NaN is what `createCpuRecorder` drops as
|
|
67
|
+
// "not a sample", where a 0 would be recorded as a confident measurement of no work.
|
|
68
|
+
const span = clock.start();
|
|
69
|
+
expect(Number.isNaN(span())).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('๐ด leaves an app that mounts cpuBudget() SERVING, instead of 500 on every path', async () => {
|
|
73
|
+
stubCpuUsage(methodNotImplemented);
|
|
74
|
+
|
|
75
|
+
// Exactly the production wiring: no clock passed, so the middleware builds its own
|
|
76
|
+
// `processCpuClock()`, and it is mounted before everything else.
|
|
77
|
+
const app = new Hono();
|
|
78
|
+
const mw = cpuBudget();
|
|
79
|
+
app.use('*', mw);
|
|
80
|
+
app.get('/healthz', (c) => c.json({ ok: true }));
|
|
81
|
+
|
|
82
|
+
const res = await app.request('http://cpu-bench.invalid/healthz');
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
85
|
+
|
|
86
|
+
// Nothing measurable was recorded โ and that is the honest outcome, not a zero.
|
|
87
|
+
const report = mw.recorder.report();
|
|
88
|
+
expect(report.available).toBe(false);
|
|
89
|
+
expect(report.routes).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('probes the DELTA call too, not just the baseline one', () => {
|
|
93
|
+
// A stub that answers the zero-argument form and throws on `cpuUsage(before)` would
|
|
94
|
+
// pass a one-call probe and then throw at the END of the first request โ the same
|
|
95
|
+
// outage, arriving one line later.
|
|
96
|
+
stubCpuUsage((before?: unknown) => {
|
|
97
|
+
if (before !== undefined) methodNotImplemented();
|
|
98
|
+
return { user: 1_000, system: 1_000 };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const clock = processCpuClock();
|
|
102
|
+
expect(clock.available).toBe(false);
|
|
103
|
+
expect(clock.source).toBe('process.cpuUsage (throws)');
|
|
104
|
+
expect(Number.isNaN(clock.start()())).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('treats a stub that answers with a non-number as unavailable, never as zero', () => {
|
|
108
|
+
stubCpuUsage(() => ({ user: undefined, system: undefined }));
|
|
109
|
+
|
|
110
|
+
const clock = processCpuClock();
|
|
111
|
+
expect(clock.available).toBe(false);
|
|
112
|
+
expect(clock.source).toBe('process.cpuUsage (non-numeric)');
|
|
113
|
+
expect(Number.isNaN(clock.start()())).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still says (absent) when the method is genuinely missing', () => {
|
|
117
|
+
stubCpuUsage(undefined);
|
|
118
|
+
|
|
119
|
+
const clock = processCpuClock();
|
|
120
|
+
expect(clock.available).toBe(false);
|
|
121
|
+
expect(clock.source).toBe('process.cpuUsage (absent)');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The direction that stops the fix from being "always unavailable": on this runtime the
|
|
125
|
+
// method works, and the clock must still measure. Without this, every case above passes
|
|
126
|
+
// against a `processCpuClock()` that returned `unavailableCpuClock()` unconditionally.
|
|
127
|
+
it('is AVAILABLE and measures on a runtime where the method really works', () => {
|
|
128
|
+
const clock = processCpuClock();
|
|
129
|
+
expect(clock.available).toBe(true);
|
|
130
|
+
expect(clock.source).toBe('process.cpuUsage');
|
|
131
|
+
expect(clock.proxy).toBe(true);
|
|
132
|
+
|
|
133
|
+
const span = clock.start();
|
|
134
|
+
let acc = 0;
|
|
135
|
+
for (let i = 0; i < 2_000_000; i += 1) acc += Math.sqrt(i % 13);
|
|
136
|
+
expect(acc).toBeGreaterThan(0);
|
|
137
|
+
const cpuMs = span();
|
|
138
|
+
expect(Number.isFinite(cpuMs)).toBe(true);
|
|
139
|
+
expect(cpuMs).toBeGreaterThan(0);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -43,10 +43,14 @@ export interface CpuClock {
|
|
|
43
43
|
* That is why {@link runCpuBench} drives its cases SERIALLY โ a serial driver is what
|
|
44
44
|
* makes this proxy sound enough to gate on. Mounted in production it is advisory: useful
|
|
45
45
|
* for ranking routes, not for a billing claim.
|
|
46
|
+
*
|
|
47
|
+
* Where the runtime has no such reading, this returns {@link unavailableCpuClock} โ a
|
|
48
|
+
* clock, never a throw. {@link probeProcessCpuUsage} is what decides, and it decides by
|
|
49
|
+
* CALLING.
|
|
46
50
|
*/
|
|
47
51
|
export function processCpuClock(): CpuClock {
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
52
|
+
const probe = probeProcessCpuUsage();
|
|
53
|
+
if (!probe.ok) return unavailableCpuClock(`process.cpuUsage (${probe.why})`);
|
|
50
54
|
return {
|
|
51
55
|
source: 'process.cpuUsage',
|
|
52
56
|
proxy: true,
|
|
@@ -61,6 +65,55 @@ export function processCpuClock(): CpuClock {
|
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
/**
|
|
69
|
+
* ๐ด **Is `process.cpuUsage()` a clock here? Answered by CALLING it, never by `typeof`.**
|
|
70
|
+
*
|
|
71
|
+
* Measured 2026-09-19 on the first real deploy of a Worker mounting `cpuBudget()`: under
|
|
72
|
+
* `nodejs_compat`, workerd's strategy for the parts of `node:process` it does not implement
|
|
73
|
+
* is to PROVIDE the method and throw when it is called โ
|
|
74
|
+
*
|
|
75
|
+
* ```
|
|
76
|
+
* Error [ERR_METHOD_NOT_IMPLEMENTED]: The process.cpuUsage method is not implemented
|
|
77
|
+
* at process.cpuUsage (node-internal:public_process:235:11)
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* โ so `typeof process.cpuUsage === 'function'` answers *yes* about the one runtime the
|
|
81
|
+
* probe exists to answer *no* about. The clock that came back threw on first use, and
|
|
82
|
+
* because `cpuBudget()` is mounted FIRST by design, the throw landed in front of the whole
|
|
83
|
+
* app: every path 500, with 843 green tests behind it, because `bun test` runs where the
|
|
84
|
+
* method works.
|
|
85
|
+
*
|
|
86
|
+
* This is a capability CLASS, not one method: `nodejs_compat` stubs a great deal of
|
|
87
|
+
* `node:process`, `node:os` and `node:v8` the same way. `src/nodeBuiltinsAreProbedByCalling.spec.ts`
|
|
88
|
+
* is the check that keeps a `typeof` gate from coming back anywhere in this library.
|
|
89
|
+
*
|
|
90
|
+
* Both call shapes are exercised, because the span uses both and only one of them is
|
|
91
|
+
* probed by the first: `cpuUsage()` for the baseline and `cpuUsage(before)` for the delta.
|
|
92
|
+
* That costs two calls at wiring time โ the same trade {@link workerCpuClock} argues for on
|
|
93
|
+
* the other side, and the alternative is a gate that reads zero for ever.
|
|
94
|
+
*/
|
|
95
|
+
function probeProcessCpuUsage(): { ok: true } | { ok: false; why: string } {
|
|
96
|
+
// Present-but-throwing and absent are both unavailable; the distinction only LABELS the
|
|
97
|
+
// clock, and is never the decision โ which is why this line may carry the excuse that
|
|
98
|
+
// `src/nodeBuiltinsAreProbedByCalling.spec.ts` refuses everywhere else.
|
|
99
|
+
// probe-by-calling:ignore โ label only; the call below is what decides.
|
|
100
|
+
const present = typeof process !== 'undefined' && typeof process.cpuUsage === 'function';
|
|
101
|
+
let reading: number;
|
|
102
|
+
try {
|
|
103
|
+
// A bare `process` that does not exist throws a ReferenceError in here, which is the
|
|
104
|
+
// same answer as a method that throws: not a clock.
|
|
105
|
+
const before = process.cpuUsage();
|
|
106
|
+
const delta = process.cpuUsage(before);
|
|
107
|
+
reading = before.user + before.system + delta.user + delta.system;
|
|
108
|
+
} catch {
|
|
109
|
+
return { ok: false, why: present ? 'throws' : 'absent' };
|
|
110
|
+
}
|
|
111
|
+
// A stub that returns something non-numeric is no more usable than one that throws, and
|
|
112
|
+
// would otherwise reach the recorder as a confident 0.00 for every route.
|
|
113
|
+
if (!Number.isFinite(reading)) return { ok: false, why: 'non-numeric' };
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
64
117
|
/**
|
|
65
118
|
* A clock over a CPU-ms reader the runtime supplies. `proxy: false` โ this is the real
|
|
66
119
|
* quantity Cloudflare bills, so a report built on it may be quoted as one.
|
|
@@ -34,6 +34,33 @@ export interface BenchCase {
|
|
|
34
34
|
* that never ran โ a green budget over a broken route.
|
|
35
35
|
*/
|
|
36
36
|
expectStatus?: number | number[] | ((status: number) => boolean);
|
|
37
|
+
/**
|
|
38
|
+
* Called before EVERY iteration of this case โ warm-up included. For a **destructive**
|
|
39
|
+
* route, rebuild the fixture here; without it the second call measures an empty database.
|
|
40
|
+
*
|
|
41
|
+
* ๐ด This is the hook a destructive route cannot be benched without. `runCpuBench` fires a
|
|
42
|
+
* case `warmup + iterations` times against ONE app instance, which is exactly right for a
|
|
43
|
+
* read and a lie for a purge: the first call runs against a populated library and the next
|
|
44
|
+
* twenty-nine against an empty one, so the p99 is a small number for the wrong reason and
|
|
45
|
+
* the budget it satisfies means nothing. `expectStatus` cannot catch it either โ the second
|
|
46
|
+
* call legitimately answers 200 with `{ deleted: 0 }`.
|
|
47
|
+
*
|
|
48
|
+
* It is **per case**, not run-wide, so the cheap read cases pay nothing for it โ neither
|
|
49
|
+
* the rebuild nor the accounting {@link runCpuBench} does around it.
|
|
50
|
+
*
|
|
51
|
+
* ๐ด **Its cost is not measured, and that is checked rather than assumed.** The recorder is
|
|
52
|
+
* written through by the `cpuBudget()` middleware inside `app.fetch`, so a hook that runs
|
|
53
|
+
* outside that call is already excluded by ordering โ but ordering is an accident, and a
|
|
54
|
+
* fixture rebuild that cost 200 ms and got attributed to the route would make this feature
|
|
55
|
+
* a worse lie than the hole it closes. So {@link runCpuBench} refuses a hook that drives
|
|
56
|
+
* the app under bench (see the error it throws), and `cpuBudget.spec.ts` proves a hook that
|
|
57
|
+
* burns real CPU leaves the route's number alone.
|
|
58
|
+
*
|
|
59
|
+
* `iteration` is 0-based and monotonic across the WHOLE case, warm-up first: warm-up gets
|
|
60
|
+
* `0 โฆ warmup-1` and the measured pass continues from `warmup`. A hook that seeds ids from
|
|
61
|
+
* it therefore never collides between the two passes.
|
|
62
|
+
*/
|
|
63
|
+
beforeEach?: (c: BenchCase, iteration: number) => void | Promise<void>;
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
export interface RunCpuBenchOpts {
|
|
@@ -73,6 +100,41 @@ function describe(c: BenchCase): string {
|
|
|
73
100
|
return `${(c.method ?? 'GET').toUpperCase()} ${c.path}`;
|
|
74
101
|
}
|
|
75
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Every request the recorder has attributed to any route so far. `samples` counts what was
|
|
105
|
+
* SEEN rather than what the reservoir retained, so this only ever goes up within a phase.
|
|
106
|
+
*/
|
|
107
|
+
function totalSamples(recorder: RunCpuBenchOpts['recorder']): number {
|
|
108
|
+
let n = 0;
|
|
109
|
+
for (const r of recorder.report().routes) n += r.samples;
|
|
110
|
+
return n;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run a case's {@link BenchCase.beforeEach}, and prove it stayed out of the histogram.
|
|
115
|
+
*
|
|
116
|
+
* ๐ด The obvious way to rebuild a fixture is to drive the app that already has the routes โ
|
|
117
|
+
* `POST /api/upload` thirty times to re-seed a library. Those requests go through
|
|
118
|
+
* `cpuBudget()` like any other, so the rebuild lands in the report as real samples on real
|
|
119
|
+
* routes, and the destructive route this hook exists to measure honestly is now benched
|
|
120
|
+
* beside rows nobody asked for. Refusing it here is cheap and only costs a case that has a
|
|
121
|
+
* hook at all.
|
|
122
|
+
*/
|
|
123
|
+
async function prepare(opts: RunCpuBenchOpts, c: BenchCase, iteration: number): Promise<void> {
|
|
124
|
+
if (!c.beforeEach) return;
|
|
125
|
+
const before = totalSamples(opts.recorder);
|
|
126
|
+
await c.beforeEach(c, iteration);
|
|
127
|
+
const after = totalSamples(opts.recorder);
|
|
128
|
+
if (after !== before) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`cpu-bench: ${describe(c)}'s beforeEach drove ${after - before} request(s) through the ` +
|
|
131
|
+
'app under bench, so the fixture rebuild is now IN the measurement it exists to keep ' +
|
|
132
|
+
'out of it. Rebuild the fixture directly โ against the database, the store, the ' +
|
|
133
|
+
'seed helper โ never through app.fetch.',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
76
138
|
async function fire(opts: RunCpuBenchOpts, c: BenchCase): Promise<void> {
|
|
77
139
|
const method = (c.method ?? c.init?.method ?? 'GET').toUpperCase();
|
|
78
140
|
const req = new Request(`${opts.origin ?? BENCH_ORIGIN}${c.path}`, { ...c.init, method });
|
|
@@ -95,15 +157,24 @@ export async function runCpuBench(opts: RunCpuBenchOpts): Promise<CpuBudgetRepor
|
|
|
95
157
|
const warmup = opts.warmup ?? 5;
|
|
96
158
|
if (!(iterations > 0)) throw new Error('runCpuBench: iterations must be > 0');
|
|
97
159
|
|
|
160
|
+
// `beforeEach` runs over the warm-up too, deliberately. Warm-up exists to reach a handler's
|
|
161
|
+
// steady state, and a destructive route warmed against the empty database it emptied warms
|
|
162
|
+
// the wrong branch โ the loop the p99 is about never runs.
|
|
98
163
|
for (const c of opts.cases) {
|
|
99
|
-
for (let i = 0; i < warmup; i += 1)
|
|
164
|
+
for (let i = 0; i < warmup; i += 1) {
|
|
165
|
+
await prepare(opts, c, i);
|
|
166
|
+
await fire(opts, c);
|
|
167
|
+
}
|
|
100
168
|
}
|
|
101
169
|
// ๐ด Everything above was warm-up. Discard it โ measuring it is the bug this guards.
|
|
102
170
|
opts.recorder.reset();
|
|
103
171
|
|
|
104
172
|
for (const c of opts.cases) {
|
|
105
173
|
const n = c.iterations ?? iterations;
|
|
106
|
-
for (let i = 0; i < n; i += 1)
|
|
174
|
+
for (let i = 0; i < n; i += 1) {
|
|
175
|
+
await prepare(opts, c, warmup + i);
|
|
176
|
+
await fire(opts, c);
|
|
177
|
+
}
|
|
107
178
|
}
|
|
108
179
|
|
|
109
180
|
return opts.recorder.report();
|
package/src/server/d1/index.ts
CHANGED
|
@@ -39,7 +39,7 @@ export {
|
|
|
39
39
|
// that does not use the query builder โ which per `../db/kysely.ts`'s own header is most
|
|
40
40
|
// of them, since business tables stay on raw statements. Measured 2026-09-18 against the
|
|
41
41
|
// published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
|
|
42
|
-
export { type InvocationD1, perInvocation } from './invocation';
|
|
42
|
+
export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation';
|
|
43
43
|
export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
|
|
44
44
|
export { createLocalD1, refuseInteractiveTransaction } from './local';
|
|
45
45
|
export {
|