Public: anyone can read this; the crew can change it.

src/core/usage_count.ts counts in memory and flushes every pending key in one transaction #2137

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2078-src-core-usage-count-ts-counts-in-memory → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/2078-src-core-usage-count-ts-counts-in-memory was merged into main as 43fadc4.

Review

Sign in to review this change.
2 files changed +277 −0
src/core/usage_count.ts added +175
@@ -0,0 +1,175 @@
1 /**
2 * Usage counts: requests by the hour, counted in memory and written in bulk.
3 *
4 * `count` is one map increment and runs no query. `flush` writes every
5 * pending key in one transaction as an upsert that adds to `n`. It runs by
6 * itself about a second after the first pending count, once 500 keys are
7 * pending (deferred, never inside `count`) and on `stop()`. The timer is
8 * `unref`'d so it never holds a process or a test open.
9 *
10 * A failed write loses nothing: the counts go back and the timer retries.
11 * Only an explicit `flush()` or `stop()` sees the error; the timer swallows it.
12 * `count` throws RangeError on a malformed event (a programming error), so a
13 * caller on the request path must wrap it in try/catch.
14 */
15
16 import type { Database } from "bun:sqlite";
17
18 export const ROLES = ["anon", "user", "admin", "agent"] as const;
19 export const SURFACES = ["api", "mcp", "web"] as const;
20 export const METHODS = [
21 "GET",
22 "HEAD",
23 "POST",
24 "PUT",
25 "PATCH",
26 "DELETE",
27 "OPTIONS",
28 ] as const;
29
30 export type UsageRole = (typeof ROLES)[number];
31 export type UsageSurface = (typeof SURFACES)[number];
32
33 export interface UsageEvent {
34 readonly pattern: string;
35 readonly method: string;
36 readonly status: number;
37 readonly role: string;
38 readonly surface: string;
39 }
40
41 export interface UsageRow {
42 readonly hour: string;
43 readonly pattern: string;
44 readonly method: string;
45 readonly status: string;
46 readonly role: string;
47 readonly surface: string;
48 readonly n: number;
49 }
50
51 export interface UsageCounter {
52 count(event: UsageEvent): void;
53 flush(): void;
54 stop(): void;
55 }
56
57 const FLUSH_MS = 1000;
58 const FLUSH_KEYS = 500;
59 const MAX_PATTERN = 200;
60 const SEP = "\u0000";
61
62 /** `2026-10-16T13`: the hour a moment falls in, UTC. */
63 export function hourOf(at: Date): string {
64 return at.toISOString().slice(0, 13);
65 }
66
67 function refuse(event: UsageEvent): string | null {
68 if (!(ROLES as readonly string[]).includes(event.role)) return "role";
69 if (!(SURFACES as readonly string[]).includes(event.surface))
70 return "surface";
71 if (!(METHODS as readonly string[]).includes(event.method)) return "method";
72 if (
73 !Number.isInteger(event.status) ||
74 event.status < 200 ||
75 event.status > 599
76 )
77 return "status";
78 if (
79 typeof event.pattern !== "string" ||
80 event.pattern.length === 0 ||
81 event.pattern.length > MAX_PATTERN
82 )
83 return "pattern";
84 return null;
85 }
86
87 export function createUsageCounter(
88 db: Database,
89 now: () => Date = () => new Date(),
90 flushMs: number = FLUSH_MS,
91 ): UsageCounter {
92 const pending = new Map<string, number>();
93 let timer: ReturnType<typeof setTimeout> | null = null;
94 let stopped = false;
95
96 function clear(): void {
97 if (timer) clearTimeout(timer);
98 timer = null;
99 }
100
101 function schedule(delay: number): void {
102 clear();
103 timer = setTimeout(() => {
104 try {
105 flush();
106 } catch {
107 // flush already put the counts back and rearmed the retry.
108 }
109 }, delay);
110 timer.unref();
111 }
112
113 function flush(): void {
114 clear();
115 if (pending.size === 0) return;
116 const batch = [...pending];
117 pending.clear();
118 try {
119 const upsert = db.query(
120 `INSERT INTO usage_count (hour, pattern, method, status, role, surface, n)
121 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
122 ON CONFLICT (hour, pattern, method, status, role, surface)
123 DO UPDATE SET n = n + excluded.n`,
124 );
125 db.transaction(() => {
126 for (const [key, n] of batch) {

A permanently failing write retries every second forever with no signal; when this is wired in, surface it once (a log line) rather than swallowing every attempt.

127 const [hour, pattern, method, status, role, surface] = key.split(
128 SEP,
129 ) as [string, string, string, string, string, string];
130 upsert.run(hour, pattern, method, status, role, surface, n);
131 }
132 })();
133 } catch (err) {
134 // Put the counts back so a failed write loses nothing.
135 for (const [key, n] of batch)
136 pending.set(key, (pending.get(key) ?? 0) + n);
137 if (!stopped) schedule(Math.max(flushMs, 1));
138 throw err;
139 }
140 }
141
142 function count(event: UsageEvent): void {
143 const bad = refuse(event);
144 if (bad) throw new RangeError(`usage count refused: bad ${bad}`);
145 if (stopped) return;
146 const key = [
147 hourOf(now()),
148 event.pattern,
149 event.method,
150 `${Math.floor(event.status / 100)}xx`,
151 event.role,
152 event.surface,
153 ].join(SEP);
154 pending.set(key, (pending.get(key) ?? 0) + 1);
155 if (pending.size >= FLUSH_KEYS) schedule(0);
156 else if (timer === null) schedule(flushMs);
157 }
158
159 function stop(): void {
160 stopped = true;
161 flush();
162 }
163
164 return { count, flush, stop };
165 }
166
167 /** Every row from `sinceHour` (`2026-10-16T13`) on, oldest hour first. */
168 export function readUsage(db: Database, sinceHour: string): UsageRow[] {
169 return db
170 .query(
171 `SELECT hour, pattern, method, status, role, surface, n FROM usage_count
172 WHERE hour >= ?1 ORDER BY hour, pattern, method, status, role, surface`,
173 )
174 .all(sinceHour) as UsageRow[];
175 }
test/usage-count.test.ts added +102
@@ -0,0 +1,102 @@
1 import { expect, test } from "bun:test";
2 import { Database } from "bun:sqlite";
3 import migration from "../src/core/migrations/20261016130000-usage-count.ts";
4 import { createUsageCounter, readUsage } from "../src/core/usage_count.ts";
5
6 const ev = {
7 pattern: "/api/issues",
8 method: "GET",
9 status: 200,
10 role: "user",
11 surface: "api",
12 };
13
14 function fresh() {
15 const db = new Database(":memory:");
16 db.run(migration);
17 return db;
18 }
19
20 test("three counts of one key are one row of 3 after a flush, none before", () => {
21 const db = fresh();
22 const c = createUsageCounter(db);
23 c.count(ev);
24 c.count({ ...ev, status: 204 });
25 c.count(ev);
26 expect(readUsage(db, "")).toEqual([]);
27 c.flush();
28 const rows = readUsage(db, "");
29 expect(rows).toHaveLength(1);
30 expect(rows[0]).toMatchObject({ n: 3, status: "2xx" });
31 c.count(ev);
32 c.stop();
33 expect(readUsage(db, "")[0]!.n).toBe(4);
34 });
35
36 test("two hours make two rows", () => {
37 const db = fresh();
38 let t = new Date("2026-10-16T10:59:00Z");
39 const c = createUsageCounter(db, () => t);
40 c.count(ev);
41 t = new Date("2026-10-16T11:00:00Z");
42 c.count(ev);
43 c.flush();
44 expect(readUsage(db, "").map((r) => r.hour)).toEqual([
45 "2026-10-16T10",
46 "2026-10-16T11",
47 ]);
48 expect(readUsage(db, "2026-10-16T11")).toHaveLength(1);
49 });
50
51 test("a refused event writes nothing", () => {
52 const db = fresh();
53 const c = createUsageCounter(db);
54 for (const bad of [
55 { role: "root" },
56 { surface: "ssh" },
57 { method: "BREW" },
58 { status: 199 },
59 { status: 600 },
60 { pattern: "" },
61 { pattern: "x".repeat(201) },
62 ])
63 expect(() => c.count({ ...ev, ...bad })).toThrow();
64 c.flush();
65 expect(readUsage(db, "")).toEqual([]);
66 });
67
68 test("it flushes by itself after its delay", async () => {
69 const db = fresh();
70 const c = createUsageCounter(db, () => new Date(), 30);
71 c.count(ev);
72 expect(readUsage(db, "")).toEqual([]);
73 await Bun.sleep(80);
74 expect(readUsage(db, "")[0]!.n).toBe(1);
75 c.stop();
76 });
77
78 test("500 pending keys flush without count running a query", async () => {
79 const db = fresh();
80 const c = createUsageCounter(db);
81 for (let i = 0; i < 500; i++) c.count({ ...ev, pattern: `/p/${i}` });
82 expect(readUsage(db, "")).toEqual([]);
83 await Bun.sleep(20);
84 expect(readUsage(db, "")).toHaveLength(500);
85 c.stop();
86 });
87
88 test("a failed write keeps the counts, retries by itself and never throws from the timer", async () => {
89 const db = fresh();
90 const c = createUsageCounter(db, () => new Date(), 20);
91 db.run("DROP TABLE usage_count");
92 c.count(ev);
93 c.count(ev);
94 await Bun.sleep(60); // timer fires and fails, then retries; no uncaught throw
95 expect(() => c.flush()).toThrow();
96 db.run(migration);
97 await Bun.sleep(60); // the rearmed timer writes the kept counts
98 expect(readUsage(db, "")[0]!.n).toBe(2);
99 c.count(ev);
100 c.stop();
101 expect(readUsage(db, "")[0]!.n).toBe(3);
102 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs