auto-model-router 0.33.0 → 0.34.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.33.0",
10
+ "version": "0.34.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.33.0",
17
+ "version": "0.34.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1192,6 +1192,16 @@ it lands on the other (`cache: keeping warm …`), and a replica that has served
1192
1192
  nothing refuses with `402 budget_exceeded` once the shared spend is past its
1193
1193
  cap — where the same cap against an empty store serves.
1194
1194
 
1195
+ On Postgres the `ledger` table is partitioned by UTC day
1196
+ (`PARTITION BY RANGE (created_at_ms)`), so `ledger.retentionDays` drops whole
1197
+ days as partitions instead of deleting rows — at the write rates a shared store
1198
+ is for, a bulk `DELETE` competes for I/O with the inserts it is making room
1199
+ for. Partitions are provisioned a few days ahead on every boot and created on
1200
+ demand if a turn arrives for a day nobody provisioned, so a write never fails
1201
+ for a missing partition. A Postgres ledger created before this is left exactly
1202
+ as it is (Postgres cannot convert a populated table in place) and logs how to
1203
+ convert; see [Data governance](docs/data-governance.md).
1204
+
1195
1205
  A SQLite deployment is unchanged: the file is still migrated in place through
1196
1206
  the nineteen shipped versions, and both halves live in the one file.
1197
1207
 
@@ -166,7 +166,12 @@ one a library default should make for them. (Before v0.21.0 the default was
166
166
  because the subquery needs the rows that are about to be deleted.
167
167
  2. `ollama_meter_samples` past the cutoff. They only calibrate the ledger's own
168
168
  Ollama estimate, so they age out with the rows they calibrate.
169
- 3. `ledger` rows past the cutoff.
169
+ 3. `ledger` rows past the cutoff. On Postgres the ledger is partitioned by UTC
170
+ day, so every day the cutoff covers *completely* is dropped as a partition —
171
+ a metadata operation — and only the boundary day the cutoff falls inside is
172
+ deleted row by row. `deleted` still counts ROWS: each partition is counted
173
+ before it is dropped, so the number means turns, not tables. SQLite has no
174
+ declarative partitioning and deletes rows as it always has.
170
175
 
171
176
  Then, when anything was deleted, `PRAGMA incremental_vacuum` hands freed pages
172
177
  back to the filesystem and `PRAGMA wal_checkpoint(TRUNCATE)` folds the WAL so
@@ -182,6 +187,42 @@ behaviour and is fine.
182
187
  honest answer to "how far back does this ledger go now", which is what the
183
188
  question was actually about, and it is reported even when nothing was deleted.
184
189
 
190
+ ### Partitions, on Postgres
191
+
192
+ A ledger row is ~2.6 kB, and a thousand-tenant deployment writes 33-165 GB a
193
+ day at 440-2200 writes a second. Deleting that competes for I/O with the
194
+ inserts it is trying to make room for, and leaves bloat autovacuum has to
195
+ chase. So on Postgres `ledger` is `PARTITION BY RANGE (created_at_ms)`, one
196
+ partition per UTC day, and retention becomes a `DROP TABLE` per day.
197
+
198
+ The bounds are the ms-epoch integers the column already holds, not a derived
199
+ timestamp, so every existing query (`created_at_ms >= ?`) prunes partitions on
200
+ its own: no read — export, spend, the caps, the decision trail — knows the
201
+ table is partitioned. The one visible consequence is the primary key, which a
202
+ partitioned table requires to include the partition key: it is
203
+ `(id, created_at_ms)` there. Ids are per-row UUIDs and a re-recorded entry
204
+ carries the same instant, so the `ON CONFLICT DO NOTHING` guard still collapses
205
+ a duplicate write.
206
+
207
+ `migrateStore` provisions yesterday through three days ahead on every boot. A
208
+ row for a day nobody provisioned is not an error either: the insert is retried
209
+ once after `ensureLedgerPartitions` creates that day (and the next few), so a
210
+ turn is never lost because a partition was late.
211
+
212
+ A Postgres ledger created before this shipped **stays exactly as it is**.
213
+ Postgres cannot convert a populated table to a partitioned one in place, and
214
+ copying a billing table at boot is not a failure mode a ledger can have — there
215
+ is no second copy of it. The router logs one line saying retention will keep
216
+ deleting rows there, with the conversion an operator can run deliberately, with
217
+ the router stopped:
218
+
219
+ ```sql
220
+ ALTER TABLE ledger RENAME TO ledger_legacy; -- then start the router:
221
+ -- boot recreates it partitioned
222
+ INSERT INTO ledger SELECT * FROM ledger_legacy; -- verify the counts, then
223
+ DROP TABLE ledger_legacy;
224
+ ```
225
+
185
226
  ### The schedule, and the route
186
227
 
187
228
  `createRetentionRunner` (`src/cost/retention.ts`) owns the once-an-hour floor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  import type { CatalogModel } from "../catalog/types.ts";
23
23
  import type { RouterConfig } from "../config/types.ts";
24
24
  import { consumePendingEstimate } from "../tokens/estimate.ts";
25
+ import { droppableLedgerPartitions, ensureLedgerPartitions } from "../util/schema.ts";
25
26
  import { jsonParam, jsonValue, num, numOrNull, type SqlDb } from "../util/sql.ts";
26
27
  import { foldBlendSamples, type BlendSample } from "./blended.ts";
27
28
  import { computeCost } from "./forecast.ts";
@@ -290,7 +291,12 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
290
291
  deps.findModel(entry.slug) ??
291
292
  null;
292
293
  const breakdown = model !== null ? computeCost(model, entry.usage) : null;
293
- await sql`
294
+ // No conflict target: on Postgres the ledger is partitioned by day, so
295
+ // its unique index has to include `created_at_ms` and `(id)` alone is
296
+ // not an arbiter. A re-recorded entry carries the same instant, which
297
+ // is the case this guard exists for.
298
+ const insert = async (): Promise<void> => {
299
+ await sql`
294
300
  INSERT INTO ledger (
295
301
  id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id,
296
302
  slug, served_slug, tier, classification_source, reasons, predicted_usd, reported_usd, usage,
@@ -309,7 +315,22 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
309
315
  ${entry.promptTokensSaved},
310
316
  ${entry.scope === undefined || entry.scope === "" ? null : entry.scope}, ${entry.redactions ?? null}
311
317
  )
312
- ON CONFLICT (id) DO NOTHING`;
318
+ ON CONFLICT DO NOTHING`;
319
+ };
320
+ try {
321
+ await insert();
322
+ } catch (err) {
323
+ // A day nobody provisioned: Postgres refuses the row with 23514
324
+ // ("no partition of relation ledger found for row"). The ledger has
325
+ // no CHECK constraints of its own, so on this statement that code
326
+ // can only mean partition routing. Create the day — and the next
327
+ // few, so this happens once rather than daily — and write again. A
328
+ // turn is never lost because a partition was late.
329
+ const code = err !== null && typeof err === "object" && "errno" in err ? String(err.errno) : "";
330
+ if (code !== "23514") throw err;
331
+ await ensureLedgerPartitions(db, entry.createdAtMs);
332
+ await insert();
333
+ }
313
334
  // Always consume the pending estimate, even when the turn failed, so a
314
335
  // dead turn's bytes can never pair with a later turn's tokens.
315
336
  const pending = consumePendingEstimate(entry.conversationKey);
@@ -520,8 +541,25 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
520
541
  // swept up too.
521
542
  await sql`DELETE FROM feedback WHERE created_at_ms < ${cutoff} OR ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ${cutoff})`;
522
543
  await sql`DELETE FROM ollama_meter_samples WHERE at_ms < ${cutoff}`;
544
+ // Whole days the cutoff covers go as metadata: a DROP of a day's
545
+ // partition is O(1) where deleting its rows is hours of I/O competing
546
+ // with the inserts it is making room for. Counted before the drop, so
547
+ // `deleted` still means rows, not partitions.
548
+ //
549
+ // ponytail: an exact COUNT(*) scans each doomed day once — a fraction of
550
+ // what deleting it costs, but not free. If that scan ever matters, read
551
+ // pg_class.reltuples instead and report the count as an estimate.
552
+ let dropped = 0;
553
+ for (const partition of await droppableLedgerPartitions(db, cutoff)) {
554
+ const rows = (await sql.unsafe(`SELECT COUNT(*) AS n FROM ${partition}`)) as { n: unknown }[];
555
+ dropped += num(rows[0]?.n);
556
+ await sql.unsafe(`DROP TABLE ${partition}`);
557
+ }
558
+ // What the drops could not cover: the boundary day the cutoff falls
559
+ // inside (a partial day, so row-wise), anything in a partition whose
560
+ // bounds could not be read, and every row on SQLite.
523
561
  const deleted = (await sql`DELETE FROM ledger WHERE created_at_ms < ${cutoff} RETURNING id`) as { id: string }[];
524
- return { deleted: deleted.length, oldestKeptMs: await oldest() };
562
+ return { deleted: dropped + deleted.length, oldestKeptMs: await oldest() };
525
563
  },
526
564
 
527
565
  async markWasted(id: string): Promise<void> {
@@ -282,7 +282,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
282
282
  // A Postgres store has no bootstrap of its own to run synchronously, so the
283
283
  // shape is created on the way up and every entry point waits for it once.
284
284
  // Resolved already on SQLite, where `openDb` just did it.
285
- const storeReady = postgres ? migrateStore(sqlDb) : Promise.resolve();
285
+ const storeReady = postgres ? migrateStore(sqlDb, log) : Promise.resolve();
286
286
  // The ledger reads and writes through the engine-agnostic handle. `findModel`
287
287
  // closes over the catalog built just below: a shared store has no catalog
288
288
  // cache of its own to price a row from.
@@ -8,19 +8,137 @@
8
8
  * has no history to migrate, and on an already-migrated file every statement
9
9
  * here is a no-op.
10
10
  *
11
+ * On Postgres the ledger is additionally PARTITIONED BY RANGE over
12
+ * `created_at_ms`, one partition per UTC day. Retention is then a metadata
13
+ * DROP rather than a bulk DELETE, which matters at the sizes this store is
14
+ * built for: a ledger row is ~2.6 kB, and 10k-50k users write 33-165 GB a day
15
+ * at 440-2200 writes a second. Deleting that competes for I/O with the inserts
16
+ * it is trying to make room for, and leaves bloat autovacuum has to chase.
17
+ *
18
+ * Every read is unchanged: the bounds ARE ms-epoch integers, so a
19
+ * `created_at_ms >= x` filter prunes partitions on its own and no query needs
20
+ * to know the table is partitioned. SQLite has no declarative partitioning and
21
+ * keeps exactly the shape it always had.
22
+ *
11
23
  * The two must agree, so the definitions below are transcribed from
12
24
  * `util/sqlite.ts` with its incremental columns folded in, and
13
25
  * `test/schema.test.ts` compares the two engine-by-engine rather than trusting
14
26
  * that they were copied correctly.
15
27
  */
16
28
 
29
+ import type { Logger } from "./log.ts";
17
30
  import type { SqlDb } from "./sql.ts";
18
31
 
32
+ const DAY_MS = 86_400_000;
33
+
34
+ /**
35
+ * Days of ledger partitions provisioned ahead of today. A turn at 23:59:59
36
+ * must not depend on a partition created at 00:00:00, and a process that runs
37
+ * for days without rebooting still has runway.
38
+ */
39
+ const LEDGER_PARTITION_AHEAD_DAYS = 3;
40
+
41
+ /** The start of the UTC day an ms-epoch instant falls in — a partition bound. */
42
+ export function ledgerDayStart(ms: number): number {
43
+ return Math.floor(ms / DAY_MS) * DAY_MS;
44
+ }
45
+
46
+ /** The partition holding the UTC day that starts at `dayStartMs`, e.g. `ledger_p20260914`. */
47
+ export function ledgerPartitionName(dayStartMs: number): string {
48
+ return `ledger_p${new Date(dayStartMs).toISOString().slice(0, 10).replaceAll("-", "")}`;
49
+ }
50
+
51
+ /**
52
+ * `IF NOT EXISTS` is not atomic on Postgres: two replicas booting against a
53
+ * fresh database both pass the existence check and the loser fails on the
54
+ * unique index over pg_type (23505), or on the table name itself (42P07 /
55
+ * 42710). Measured: one of two replicas started together died with
56
+ * "duplicate key value violates unique constraint pg_type_typname_nsp_index".
57
+ * The condition those errors report is the condition the statement asked to
58
+ * tolerate, so they are the success case arriving from the other replica.
59
+ */
60
+ const RACED = new Set(["23505", "42P07", "42710"]);
61
+
62
+ async function createIfAbsent(db: SqlDb, statement: string): Promise<void> {
63
+ try {
64
+ await db.sql.unsafe(statement);
65
+ } catch (err) {
66
+ const code = err !== null && typeof err === "object" && "errno" in err ? String(err.errno) : "";
67
+ if (!RACED.has(code)) throw err;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * How this store holds the ledger.
73
+ *
74
+ * `plain` is SQLite, and also a Postgres ledger created before partitioning
75
+ * shipped: Postgres cannot convert a populated table to a partitioned one in
76
+ * place, so an existing deployment keeps the table it has (see `migrateStore`).
77
+ */
78
+ export async function ledgerLayout(db: SqlDb): Promise<"partitioned" | "plain" | "absent"> {
79
+ if (db.dialect !== "postgres") return (await db.tableExists("ledger")) ? "plain" : "absent";
80
+ const rows = await db.query<{ relkind: string }>("SELECT relkind FROM pg_class WHERE oid = to_regclass('ledger')");
81
+ const kind = rows[0]?.relkind;
82
+ if (kind === "p") return "partitioned";
83
+ return kind === undefined ? "absent" : "plain";
84
+ }
85
+
86
+ /**
87
+ * Creates the day partitions around `aroundMs` — yesterday through
88
+ * `aheadDays` — so the write path never meets a day nobody provisioned.
89
+ * Idempotent and a no-op on any store whose ledger is not partitioned.
90
+ *
91
+ * Yesterday is included because a row's `created_at_ms` is the instant the turn
92
+ * STARTED: a boot just after midnight, or a replica whose clock trails the one
93
+ * that provisioned, can still present the previous day.
94
+ */
95
+ export async function ensureLedgerPartitions(db: SqlDb, aroundMs = Date.now(), aheadDays = LEDGER_PARTITION_AHEAD_DAYS): Promise<void> {
96
+ if ((await ledgerLayout(db)) !== "partitioned") return;
97
+ const today = ledgerDayStart(aroundMs);
98
+ for (let start = today - DAY_MS; start <= today + aheadDays * DAY_MS; start += DAY_MS) {
99
+ await createIfAbsent(
100
+ db,
101
+ `CREATE TABLE IF NOT EXISTS ${ledgerPartitionName(start)} PARTITION OF ledger FOR VALUES FROM (${start}) TO (${start + DAY_MS})`,
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Ledger partitions whose whole range ends at or before `upToMs`, oldest
108
+ * first: the ones a retention cutoff covers completely.
109
+ *
110
+ * Bounds are read from the catalog rather than parsed out of the partition's
111
+ * name, so a partition an operator attached by hand is judged on what it
112
+ * actually holds, and one with an unreadable or DEFAULT bound is skipped
113
+ * rather than guessed at — the rows in it are then pruned by the row-wise
114
+ * DELETE, which is slower but never drops a day it did not verify.
115
+ */
116
+ export async function droppableLedgerPartitions(db: SqlDb, upToMs: number): Promise<string[]> {
117
+ if ((await ledgerLayout(db)) !== "partitioned") return [];
118
+ const rows = await db.query<{ name: string; bound: string | null }>(
119
+ `SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
120
+ FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
121
+ WHERE i.inhparent = to_regclass('ledger')`,
122
+ );
123
+ const covered: { name: string; endMs: number }[] = [];
124
+ for (const row of rows) {
125
+ const upper = /TO \('?(-?\d+)'?\)/.exec(row.bound ?? "");
126
+ if (upper === null) continue;
127
+ const endMs = Number(upper[1]);
128
+ if (endMs <= upToMs) covered.push({ name: row.name, endMs });
129
+ }
130
+ covered.sort((a, b) => a.endMs - b.endMs);
131
+ return covered.map((p) => p.name);
132
+ }
133
+
19
134
  /**
20
135
  * Creates every table and index the router uses. Idempotent, so boot order
21
136
  * never matters — the property the SQLite bootstrap has always had.
22
137
  */
23
- export async function migrateStore(db: SqlDb): Promise<void> {
138
+ export async function migrateStore(db: SqlDb, log?: Logger): Promise<void> {
139
+ // Postgres partitions the ledger by day; SQLite has no declarative
140
+ // partitioning, so it keeps the single table it always had.
141
+ const partitioned = db.dialect === "postgres" && (await ledgerLayout(db)) !== "plain";
24
142
  const json = db.type("json");
25
143
  const float = db.type("float");
26
144
  const big = db.type("bigint");
@@ -41,8 +159,14 @@ export async function migrateStore(db: SqlDb): Promise<void> {
41
159
 
42
160
  // One row per dispatched upstream generation. The columns nineteen
43
161
  // migrations added are declared here as they finally stand.
162
+ //
163
+ // Partitioned by day on Postgres, which forces the primary key to
164
+ // include the partition key — a unique index on a partitioned table has
165
+ // to. `id` alone stays unique in practice (it is a fresh UUID per entry),
166
+ // and a re-recorded entry carries the same `created_at_ms`, so the
167
+ // ON CONFLICT guard in `record` still collapses it.
44
168
  `CREATE TABLE IF NOT EXISTS ledger (
45
- id TEXT PRIMARY KEY,
169
+ id TEXT${partitioned ? "" : " PRIMARY KEY"},
46
170
  created_at_ms ${big} NOT NULL,
47
171
  conversation_key TEXT NOT NULL,
48
172
  session_id TEXT NOT NULL,
@@ -77,8 +201,11 @@ export async function migrateStore(db: SqlDb): Promise<void> {
77
201
  hold_arm INTEGER,
78
202
  prompt_tokens_saved INTEGER,
79
203
  scope TEXT,
80
- redactions INTEGER
81
- )`,
204
+ redactions INTEGER${partitioned ? ",\n\t\t\tPRIMARY KEY (id, created_at_ms)" : ""}
205
+ )${partitioned ? " PARTITION BY RANGE (created_at_ms)" : ""}`,
206
+ // The composite key above cannot serve a lookup by `id` alone, which is
207
+ // what the feedback join and `markWasted` do.
208
+ ...(partitioned ? ["CREATE INDEX IF NOT EXISTS idx_ledger_id ON ledger (id)"] : []),
82
209
  "CREATE INDEX IF NOT EXISTS idx_ledger_conversation ON ledger (conversation_key)",
83
210
  "CREATE INDEX IF NOT EXISTS idx_ledger_created ON ledger (created_at_ms)",
84
211
  "CREATE INDEX IF NOT EXISTS idx_ledger_slug ON ledger (slug)",
@@ -166,22 +293,24 @@ export async function migrateStore(db: SqlDb): Promise<void> {
166
293
  )`,
167
294
  ];
168
295
 
169
- // `IF NOT EXISTS` is not atomic on Postgres: two replicas booting against a
170
- // fresh database both pass the existence check and the loser fails on the
171
- // unique index over pg_type (23505), or on the table name itself (42P07 /
172
- // 42710). Measured: one of two replicas started together died with
173
- // "duplicate key value violates unique constraint pg_type_typname_nsp_index".
174
- // The condition those errors report is the condition the statement asked to
175
- // tolerate, so they are the success case arriving from the other replica.
176
- const RACED = new Set(["23505", "42P07", "42710"]);
177
- for (const statement of statements) {
178
- try {
179
- await db.sql.unsafe(statement);
180
- } catch (err) {
181
- const code = (err as { errno?: unknown }).errno;
182
- if (!RACED.has(String(code))) throw err;
183
- }
296
+ for (const statement of statements) await createIfAbsent(db, statement);
297
+
298
+ if (db.dialect !== "postgres") return;
299
+ if (partitioned) {
300
+ // Ahead of need: the write path must never be the thing that discovers a
301
+ // day has no partition (it recovers, but a bill should not depend on that).
302
+ await ensureLedgerPartitions(db);
303
+ return;
184
304
  }
305
+ // A Postgres ledger from before this shipped. Postgres cannot convert a
306
+ // populated table to a partitioned one in place, and copying a billing table
307
+ // at boot is the one failure mode the ledger must not have, so the existing
308
+ // table is left exactly as it is and retention keeps deleting rows. The
309
+ // conversion is an operator's decision, taken with the router stopped.
310
+ log?.warn("ledger is not partitioned by day, so retention will delete rows instead of dropping partitions; partitioning applies to new deployments", {
311
+ convert:
312
+ "stop every replica, then: ALTER TABLE ledger RENAME TO ledger_legacy; start the router (it recreates ledger partitioned); INSERT INTO ledger SELECT * FROM ledger_legacy; verify the counts match; DROP TABLE ledger_legacy",
313
+ });
185
314
  }
186
315
 
187
316
  /** Every table `migrateStore` creates, for tests and for teardown. */
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The ledger's day partitions, on every engine it claims to support.
3
+ *
4
+ * SQLite runs always (a temp file) and MUST be unchanged by any of this: it has
5
+ * no declarative partitioning, so every assertion below has to hold on the
6
+ * single table it always had. Postgres runs when AMR_ROUTER_TEST_PG points at
7
+ * one, following this repo's convention for store tests.
8
+ *
9
+ * What is actually being defended: retention on a store taking 33-165 GB a day
10
+ * cannot be a bulk DELETE, and the conversion to something cheaper must not
11
+ * change a single figure a bill is read from — nor lose a row on a deployment
12
+ * that upgrades into it.
13
+ */
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
19
+ import type { RouterConfig } from "../src/config/types.ts";
20
+ import { createSqlLedger } from "../src/cost/ledger-sql.ts";
21
+ import { exportRows, spendUsdSince } from "../src/cost/views.ts";
22
+ import { EMPTY_USAGE, type AsyncLedger, type LedgerEntry } from "../src/cost/types.ts";
23
+ import type { Logger } from "../src/util/log.ts";
24
+ import {
25
+ droppableLedgerPartitions,
26
+ ensureLedgerPartitions,
27
+ ledgerDayStart,
28
+ ledgerLayout,
29
+ ledgerPartitionName,
30
+ migrateStore,
31
+ } from "../src/util/schema.ts";
32
+ import { num, openSqlDb, type SqlDb } from "../src/util/sql.ts";
33
+
34
+ const DAY = 86_400_000;
35
+ const PG = process.env.AMR_ROUTER_TEST_PG;
36
+
37
+ const engines: { name: string; url: string; partitions: boolean }[] = [
38
+ { name: "sqlite", url: `sqlite://${join(tmpdir(), `ledger-part-${process.pid}-${Date.now()}.db`)}`, partitions: false },
39
+ ...(PG === undefined ? [] : [{ name: "postgres", url: PG, partitions: true }]),
40
+ ];
41
+
42
+ function entry(over: Partial<LedgerEntry> & { id: string }): LedgerEntry {
43
+ return {
44
+ createdAtMs: Date.now(),
45
+ conversationKey: `conv-${over.id}`,
46
+ sessionId: `sess-${over.id}`,
47
+ turn: 1,
48
+ requestedModel: "auto",
49
+ harnessId: "hp",
50
+ ompSessionId: `omp-${over.id}`,
51
+ slug: "x/model",
52
+ servedSlug: "x/model",
53
+ tier: "simple",
54
+ classificationSource: "heuristic",
55
+ reasons: ["cheapest"],
56
+ predictedUsd: 0.001,
57
+ reportedUsd: 0.002,
58
+ usage: { ...EMPTY_USAGE, promptTokens: 100, completionTokens: 10 },
59
+ attempt: 0,
60
+ escalationSignal: null,
61
+ latencyMs: 10,
62
+ ttftMs: 5,
63
+ finishReason: "stop",
64
+ wasted: false,
65
+ upstreamGenerationId: null,
66
+ error: null,
67
+ features: null,
68
+ score: null,
69
+ confidence: null,
70
+ task: null,
71
+ classifierReasons: null,
72
+ exploredFrom: null,
73
+ holdArm: null,
74
+ promptTokensSaved: null,
75
+ scope: "team/proj",
76
+ redactions: null,
77
+ ...over,
78
+ } as unknown as LedgerEntry;
79
+ }
80
+
81
+ /** Live day partitions of the ledger, by name; empty on an unpartitioned store. */
82
+ async function partitionsOf(db: SqlDb): Promise<string[]> {
83
+ if ((await ledgerLayout(db)) !== "partitioned") return [];
84
+ const rows = await db.query<{ name: string }>(
85
+ "SELECT c.relname AS name FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid WHERE i.inhparent = to_regclass('ledger') ORDER BY 1",
86
+ );
87
+ return rows.map((r) => r.name);
88
+ }
89
+
90
+ for (const engine of engines) {
91
+ describe(`ledger partitioning on ${engine.name}`, () => {
92
+ let db: SqlDb;
93
+ const cfg: RouterConfig = { ...structuredClone(DEFAULT_CONFIG), ledger: { ...DEFAULT_CONFIG.ledger, path: engine.url } };
94
+ let ledger: AsyncLedger;
95
+
96
+ /** A fresh store: what a NEW deployment boots into, not what an old one upgraded to. */
97
+ const fresh = async (): Promise<void> => {
98
+ if (db.dialect === "postgres") await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
99
+ await migrateStore(db);
100
+ };
101
+
102
+ beforeAll(async () => {
103
+ db = openSqlDb(engine.url);
104
+ await fresh();
105
+ ledger = createSqlLedger(db, cfg, { findModel: () => null });
106
+ });
107
+
108
+ afterAll(async () => {
109
+ // Leave the shared database in the shape a fresh boot produces, whatever
110
+ // the legacy test below did to it.
111
+ await fresh();
112
+ await db.close();
113
+ });
114
+
115
+ beforeEach(async () => {
116
+ await db.sql.unsafe("DELETE FROM ledger");
117
+ await db.sql.unsafe("DELETE FROM feedback");
118
+ });
119
+
120
+ test("a fresh store partitions by day on postgres and not on sqlite, and a turn writes and reads back", async () => {
121
+ expect(await ledgerLayout(db)).toBe(engine.partitions ? "partitioned" : "plain");
122
+ // Ahead of need: today's partition exists before any turn arrives, and
123
+ // so do the next few days, so a turn at 23:59:59 cannot be the first to
124
+ // need tomorrow's.
125
+ const today = ledgerDayStart(Date.now());
126
+ const expected = engine.partitions
127
+ ? [-1, 0, 1, 2, 3].map((d) => ledgerPartitionName(today + d * DAY))
128
+ : [];
129
+ expect(await partitionsOf(db)).toEqual(expected);
130
+
131
+ await ledger.record(entry({ id: "t1", reportedUsd: 0.5 }));
132
+ const recent = await ledger.recentEntries(10);
133
+ expect(recent.map((e) => e.id)).toEqual(["t1"]);
134
+ // The JSON columns and the money survive the trip through a partition.
135
+ expect(recent[0]?.usage.promptTokens).toBe(100);
136
+ expect(recent[0]?.reasons).toEqual(["cheapest"]);
137
+ expect(await ledger.spendSince(0, "hp")).toBeCloseTo(0.5, 9);
138
+ expect((await ledger.latestForSession("omp-t1"))?.id).toBe("t1");
139
+ });
140
+
141
+ test("a turn for a day nobody provisioned still records, and the day exists afterwards", async () => {
142
+ // 40 days back is outside every partition `migrateStore` created. On
143
+ // Postgres the insert is refused once, the day is created, and the row
144
+ // lands — the caller never sees a failure, because a ledger row is a
145
+ // bill and there is no second copy of it.
146
+ const backdated = Date.now() - 40 * DAY;
147
+ await ledger.record(entry({ id: "late", createdAtMs: backdated, reportedUsd: 0.25 }));
148
+ // And forwards: a process that has been up for a week.
149
+ const ahead = Date.now() + 9 * DAY;
150
+ await ledger.record(entry({ id: "ahead", createdAtMs: ahead, reportedUsd: 0.75 }));
151
+
152
+ expect((await ledger.recentEntries(10)).map((e) => e.id).sort()).toEqual(["ahead", "late"]);
153
+ expect(await ledger.spendSince(0, "hp")).toBeCloseTo(1, 9);
154
+ if (!engine.partitions) return;
155
+ const live = await partitionsOf(db);
156
+ expect(live).toContain(ledgerPartitionName(ledgerDayStart(backdated)));
157
+ expect(live).toContain(ledgerPartitionName(ledgerDayStart(ahead)));
158
+ });
159
+
160
+ test("prune drops whole days, keeps the recent window, and counts rows rather than partitions", async () => {
161
+ // One instant for both the rows and the cutoff: `oldestKeptMs` is an
162
+ // exact row timestamp, so a second Date.now() would be milliseconds off.
163
+ const nowMs = Date.now();
164
+ for (const age of [400, 200, 1]) {
165
+ await ledger.record(entry({ id: `p${age}`, createdAtMs: nowMs - age * DAY }));
166
+ }
167
+ expect(await ledger.prune(365, nowMs)).toEqual({ deleted: 1, oldestKeptMs: expect.any(Number) });
168
+ expect((await ledger.recentEntries(10)).map((e) => e.id).sort()).toEqual(["p1", "p200"]);
169
+ if (engine.partitions) {
170
+ // The dropped day is gone as a table, not just as rows.
171
+ expect(await partitionsOf(db)).not.toContain(ledgerPartitionName(ledgerDayStart(nowMs - 400 * DAY)));
172
+ expect(await partitionsOf(db)).toContain(ledgerPartitionName(ledgerDayStart(nowMs - 200 * DAY)));
173
+ }
174
+ expect((await ledger.prune(30, nowMs)).deleted).toBe(1);
175
+ expect((await ledger.recentEntries(10)).map((e) => e.id)).toEqual(["p1"]);
176
+ // Nothing left to remove, and the window is reported from real rows.
177
+ expect(await ledger.prune(30, nowMs)).toEqual({ deleted: 0, oldestKeptMs: nowMs - DAY });
178
+ });
179
+
180
+ test("the boundary day the cutoff falls inside is pruned row by row, so the count stays honest", async () => {
181
+ // The cutoff at noon splits a day: its partition holds rows on both
182
+ // sides of it and must not be dropped, while the day before it goes
183
+ // whole. `deleted` has to count both kinds of row and nothing else.
184
+ const cutoff = ledgerDayStart(Date.now()) + 12 * 3_600_000;
185
+ const nowMs = cutoff + DAY;
186
+ await ledger.record(entry({ id: "b_before", createdAtMs: cutoff - 60_000 }));
187
+ await ledger.record(entry({ id: "b_after", createdAtMs: cutoff + 60_000 }));
188
+ await ledger.record(entry({ id: "b_whole", createdAtMs: cutoff - 2 * DAY }));
189
+
190
+ expect((await ledger.prune(1, nowMs)).deleted).toBe(2);
191
+ expect((await ledger.recentEntries(10)).map((e) => e.id)).toEqual(["b_after"]);
192
+ if (!engine.partitions) return;
193
+ const live = await partitionsOf(db);
194
+ expect(live).toContain(ledgerPartitionName(ledgerDayStart(cutoff)));
195
+ expect(live).not.toContain(ledgerPartitionName(ledgerDayStart(cutoff - 2 * DAY)));
196
+ // And the partial day was not covered by a drop: the helper only ever
197
+ // offers partitions whose whole range is behind the cutoff.
198
+ expect(await droppableLedgerPartitions(db, cutoff)).not.toContain(ledgerPartitionName(ledgerDayStart(cutoff)));
199
+ });
200
+
201
+ test("export, spend and cap reads see exactly what they saw before partitioning", async () => {
202
+ const day = ledgerDayStart(Date.now()) + 3_600_000;
203
+ await ledger.record(entry({ id: "v1", createdAtMs: day, reportedUsd: 1 }));
204
+ await ledger.record(entry({ id: "v2", createdAtMs: day + 60_000, reportedUsd: 2, harnessId: "other" }));
205
+ await ledger.record(entry({ id: "v3", createdAtMs: day - 2 * DAY, reportedUsd: 4 }));
206
+
207
+ const rows = await exportRows(db, 0, null);
208
+ expect(rows.map((r) => [r.day, r.harnessId, r.dispatches, r.spendUsd])).toEqual([
209
+ [new Date(day - 2 * DAY).toISOString().slice(0, 10), "hp", 1, 4],
210
+ [new Date(day).toISOString().slice(0, 10), "hp", 1, 1],
211
+ [new Date(day).toISOString().slice(0, 10), "other", 1, 2],
212
+ ]);
213
+ expect(rows.every((r) => r.scope === "team/proj")).toBe(true);
214
+ // The cap reads: whole ledger, one harness, one window, one project.
215
+ expect(await spendUsdSince(db, 0, null)).toBeCloseTo(7, 9);
216
+ expect(await spendUsdSince(db, 0, ["hp"])).toBeCloseTo(5, 9);
217
+ expect(await spendUsdSince(db, day, null)).toBeCloseTo(3, 9);
218
+ expect(await spendUsdSince(db, 0, null, "team/proj")).toBeCloseTo(7, 9);
219
+ expect(await ledger.spendSince(day - DAY, "hp")).toBeCloseTo(1, 9);
220
+ expect(await ledger.conversationSpend("conv-v3")).toBeCloseTo(4, 9);
221
+ expect((await ledger.trust("x/model", "hp"))?.attempts).toBe(2);
222
+ });
223
+
224
+ test("ensuring partitions is idempotent and never touches an unpartitioned store", async () => {
225
+ const before = await partitionsOf(db);
226
+ await ensureLedgerPartitions(db);
227
+ await ensureLedgerPartitions(db);
228
+ expect(await partitionsOf(db)).toEqual(before);
229
+ expect(await ledgerLayout(db)).toBe(engine.partitions ? "partitioned" : "plain");
230
+ });
231
+ });
232
+ }
233
+
234
+ /**
235
+ * The upgrade path. A deployment with a populated, unpartitioned `ledger` is
236
+ * holding billing history with no second copy, and Postgres cannot convert a
237
+ * table to a partitioned one in place — so the chosen behaviour is to leave it
238
+ * exactly as it is, say so once, and keep working.
239
+ */
240
+ describe.skipIf(PG === undefined)("a legacy unpartitioned postgres ledger", () => {
241
+ let db: SqlDb;
242
+ const cfg: RouterConfig = { ...structuredClone(DEFAULT_CONFIG), ledger: { ...DEFAULT_CONFIG.ledger, path: PG ?? "" } };
243
+ const warnings: string[] = [];
244
+ const capture: Logger = {
245
+ error: () => {},
246
+ warn: (msg) => warnings.push(msg),
247
+ info: () => {},
248
+ debug: () => {},
249
+ };
250
+
251
+ beforeAll(async () => {
252
+ db = openSqlDb(PG as string);
253
+ await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
254
+ await migrateStore(db);
255
+ // Reshape it into what an older release left behind: the same columns, one
256
+ // plain table. `LIKE` copies the column list off the partitioned parent,
257
+ // so this fixture cannot drift from the real schema.
258
+ await db.sql.unsafe("DROP TABLE IF EXISTS ledger_legacy_fixture");
259
+ await db.sql.unsafe("CREATE TABLE ledger_legacy_fixture (LIKE ledger INCLUDING DEFAULTS)");
260
+ await db.sql.unsafe("DROP TABLE ledger");
261
+ await db.sql.unsafe("ALTER TABLE ledger_legacy_fixture RENAME TO ledger");
262
+ await db.sql.unsafe("ALTER TABLE ledger ADD PRIMARY KEY (id)");
263
+ });
264
+
265
+ afterAll(async () => {
266
+ await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
267
+ await migrateStore(db);
268
+ await db.close();
269
+ });
270
+
271
+ test("it is left alone, its rows are kept, and the operator is told once how to convert", async () => {
272
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
273
+ await ledger.record(entry({ id: "legacy1", createdAtMs: Date.now() - 400 * DAY, reportedUsd: 3 }));
274
+ warnings.length = 0;
275
+
276
+ await migrateStore(db, capture);
277
+
278
+ expect(await ledgerLayout(db)).toBe("plain");
279
+ expect(num((await db.one<{ n: unknown }>("SELECT COUNT(*) AS n FROM ledger"))?.n)).toBe(1);
280
+ expect(warnings.filter((w) => w.includes("not partitioned"))).toHaveLength(1);
281
+ // Still a working ledger, and retention still applies — row-wise, which is
282
+ // the cost of not converting.
283
+ await ledger.record(entry({ id: "legacy2", reportedUsd: 5 }));
284
+ expect(await ledger.spendSince(0, "hp")).toBeCloseTo(8, 9);
285
+ expect(await droppableLedgerPartitions(db, Date.now())).toEqual([]);
286
+ expect((await ledger.prune(365)).deleted).toBe(1);
287
+ expect((await ledger.recentEntries(10)).map((e) => e.id)).toEqual(["legacy2"]);
288
+ });
289
+
290
+ test("every read returns the same figures on the partitioned table as on the legacy one", async () => {
291
+ // The same three turns, read twice: once through the table a deployment
292
+ // already has, once through the partitioned one a fresh boot creates. Any
293
+ // difference here is a bill that changed because of a storage decision.
294
+ const rows = [
295
+ entry({ id: "same1", createdAtMs: ledgerDayStart(Date.now()) + 3_600_000, reportedUsd: 1 }),
296
+ entry({ id: "same2", createdAtMs: ledgerDayStart(Date.now()) + 7_200_000, reportedUsd: 2, harnessId: "other" }),
297
+ entry({ id: "same3", createdAtMs: ledgerDayStart(Date.now()) - 5 * DAY, reportedUsd: 4 }),
298
+ ];
299
+ const read = async (): Promise<unknown> => {
300
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
301
+ await db.sql.unsafe("DELETE FROM ledger");
302
+ for (const row of rows) await ledger.record(row);
303
+ return {
304
+ exported: await exportRows(db, 0, null),
305
+ all: await spendUsdSince(db, 0, null),
306
+ scoped: await spendUsdSince(db, 0, ["hp"]),
307
+ project: await spendUsdSince(db, 0, null, "team/proj"),
308
+ trust: await ledger.trust("x/model"),
309
+ entries: (await ledger.recentEntries(10)).map((e) => e.id),
310
+ };
311
+ };
312
+
313
+ expect(await ledgerLayout(db)).toBe("plain");
314
+ const legacy = await read();
315
+
316
+ await db.sql.unsafe("DROP TABLE ledger");
317
+ await migrateStore(db);
318
+ expect(await ledgerLayout(db)).toBe("partitioned");
319
+ expect(await read()).toEqual(legacy);
320
+ });
321
+ });