Skip to content

SQL and SQLite: schema, transactions, WAL mode, indexes, aggregates

Modulelang.11 · practice · SQL · Pass 7 · 4 to 6 h
You buildprimers/lang.11/: five queries over the usage ledger (q1_tenant_totals.sql to q5_ttft_p95.sql), an index (indexes.sql), connection settings (pragmas.sql), a rollup table (rollup.sql), and the transaction that records one request (record.sql)
Contractthe table your queries read: course/contracts/formats/usage.v1.sql, the gateway’s usage ledger
Testscourse/tests/lang.11/check runs your SQL with Python’s built-in sqlite3 against the 240 fixture rows of course/fixtures/lang.11/usage_rows.jsonl and compares every answer with one computed in plain Python (what each test checks: section 4)
Needsreading: lang.02 shell and processes (primer) for the sqlite3 command line
Used byno call site (a primer): gw.07 applies it next, writing the same table from Go and serving these queries through GET /admin/v1/usage
MilestoneMS-gateway
Optional depthSQLite: SQL as understood by SQLite (free); SQLite: write-ahead logging (free); SQLite: query planning (free); Use The Index, Luke (free)
  • A table is a set of rows with typed columns; constraints (PRIMARY KEY, CHECK, NOT NULL) make the database refuse bad rows instead of storing them (test_rollup_schema).
  • GROUP BY turns rows into one row per group; COUNT(*) counts rows, SUM adds values and is NULL over no rows, so totals use COALESCE(SUM(x), 0) (test_q1_tenant_totals_on_the_fixture).
  • A transaction makes several statements one unit: either every change is committed or none is, and ON CONFLICT DO NOTHING plus changes() makes a replayed write a no-op (test_record_is_atomic, test_record_replay_is_a_no_op).
  • WAL mode lets readers and one writer work at once and keeps every committed transaction across a process crash; it is stored in the file, while synchronous and busy_timeout are per connection (test_pragmas_set_wal_and_connection_settings).
  • An index is a sorted copy of some columns; put equality columns first and the range column last, and read EXPLAIN QUERY PLAN to see SEARCH instead of SCAN (test_index_serves_the_served_model_query).
Terminal window
ol start lang.11 # records that you started; the exercise is primers/lang.11/
ol tests lang.11 # read the test catalog first
mkdir -p primers/lang.11 # write the nine files of section 4
sqlite3 /tmp/u.db < contracts/formats/usage.v1.sql # try things by hand
sqlite3 /tmp/u.db ".param set :tenant acme" ".param set :since 0" ".param set :until 9999999999999" \
".read primers/lang.11/q1_tenant_totals.sql"
ol check lang.11 # exit code is the verdict

In Pass 7 the gateway grows a memory. Until now it forgot every request the moment the response ended, so nobody could say how many tokens acme used yesterday, which key is burning through its budget, or whether the canary model is slower than the stable one. gw.07 adds a usage ledger: one row per request in a SQLite database, written by the gateway and read through its admin API, by the agent’s query_usage tool (ag.04), and by the noisy-neighbor drill (ops.09). Every one of those readers is a SQL query, and the ledger is only useful if its writes survive a crash and its reads are right at the edges: an empty window, a tie, a missing value. This primer teaches that SQL on the real table before you write the Go that fills it.

A relational database stores tables. A table has named columns, each with a type, and holds rows, one value per column. SQLite’s types are INTEGER, REAL, TEXT, BLOB, and the absent value NULL. Open contracts/formats/usage.v1.sql: the usage table has one row per request, with request_id TEXT NOT NULL PRIMARY KEY, ts_ms INTEGER (the start time in Unix milliseconds), tenant, key_id, model, status, error_code (NULL on success), token counts, and ttft_ms REAL (NULL when no content byte was sent).

A constraint is a rule the database enforces on every write:

ConstraintMeaningIn the ledger
PRIMARY KEYthe column (or tuple of columns) identifies one row; a second row with the same key is refusedrequest_id: one row per request
NOT NULLthe column always has a valueevery column but error_code and ttft_ms
CHECK (expr)expr must be true for every rowcached_tokens <= prompt_tokens, stream IN (0, 1)
DEFAULT vthe value when an INSERT leaves the column outapi_version DEFAULT '1'

CREATE TABLE IF NOT EXISTS makes a schema file safe to apply on every start: the second time it does nothing. Your rollup.sql defines a second table, usage_hourly, with one row per tenant and hour: its primary key is the pair (tenant, hour_ms), and a CHECK (hour_ms % 3600000 = 0) keeps every hour_ms on an hour boundary.

A program talks to a database through a connection. A pragma is a SQLite setting, PRAGMA name = value. Some are stored in the database file and apply to every connection; most belong to the one connection that ran them. That difference matters as soon as a program keeps a pool of connections (Go’s database/sql does).

PragmaScopeValue for the ledgerWhy
journal_modethe fileWALsee below
synchronousconnectionNORMAL (1)with WAL, a commit survives a process crash; only a power cut can lose the last commits, and no fsync per commit
busy_timeoutconnectionat least 1000 msa writer that finds the database locked waits up to this long instead of failing with SQLITE_BUSY
foreign_keysconnectionONSQLite enforces REFERENCES only when this is on

The journal. A database must survive a crash in the middle of a write. SQLite’s default rollback journal copies each page it is about to change into db-journal, then changes the database in place; a crash is undone from the copy on the next open. While a writer works, readers wait. In write-ahead logging (WAL) the database file is not touched by a commit: the changed pages are appended to db-wal, followed by a commit record. Readers keep reading the old pages plus every committed frame, so readers never block the writer and the writer never blocks readers. On open after a crash, SQLite replays the WAL up to the last commit record and ignores a torn tail. Every so often a checkpoint copies the WAL back into the database file. There is still only one writer at a time, which is what busy_timeout is for.

2.3 Reading: SELECT, WHERE, aggregates, GROUP BY

Section titled “2.3 Reading: SELECT, WHERE, aggregates, GROUP BY”

A query is evaluated in this order: FROM picks the table, WHERE keeps the rows whose condition is true, GROUP BY collects rows with equal values of the grouping expressions, the SELECT list computes one output row per group (or per row without GROUP BY), ORDER BY sorts, LIMIT keeps the first rows.

An aggregate turns the rows of a group into one value:

SymbolMeaning
GGthe rows of one group
∣G∣\lvert G \rvertthe number of rows in GG
xrx_rthe value of column xx in row rr, possibly NULL
AggregateValueOver no rows
COUNT(*)∣G∣\lvert G \rvert0
COUNT(x)the number of rows where xrx_r is not NULL0
SUM(x)∑r∈G, xr≠NULLxr\sum_{r \in G,\ x_r \ne \text{NULL}} x_rNULL
AVG(x), MIN(x), MAX(x)as named, ignoring NULLsNULL

The NULL in the last column is the classic surprise: the total tokens of a tenant with no requests is NULL, not 0. COALESCE(a, b) returns a unless it is NULL, so COALESCE(SUM(prompt_tokens), 0) is the total you want. Without GROUP BY an aggregate query returns exactly one row even when no row matched; with GROUP BY an empty input gives no rows.

A comparison is 1 or 0 in SQLite, so SUM(status >= 400 OR error_code IS NOT NULL) counts errors: refused requests (status 400 and up) and streams that failed after their first byte (status 200 with an error code). Note IS NOT NULL: error_code != NULL is NULL for every row, never true.

Times are integers (Unix milliseconds), so a window is two comparisons. Use half-open windows, ts_ms >= :since AND ts_ms < :until: then the hours [10:00, 11:00) and [11:00, 12:00) add up to [10:00, 12:00) and a request at exactly 11:00:00.000 is counted once. A bucket is an expression in GROUP BY: the start of the hour of ts_ms is ts_ms - ts_ms % 3600000 (% is the remainder), because an hour is 3.6×1063.6 \times 10^6 ms.

A window function computes a value for each row from a set of related rows, without collapsing them: ROW_NUMBER() OVER (PARTITION BY model ORDER BY ttft_ms) numbers each model’s rows 1, 2, 3, … in TTFT order, and COUNT(*) OVER (PARTITION BY model) puts the model’s row count on every row.

The nearest-rank percentile: for nn values sorted ascending v1≤⋯≤vnv_1 \le \dots \le v_n, the pp-th percentile is vkv_k with k=⌈p n/100⌉k = \lceil p\,n / 100 \rceil. For p=95p = 95, in integers, k=⌊(95n+99)/100⌋k = \lfloor (95 n + 99) / 100 \rfloor (adding 99 before the floor division rounds up).

SymbolMeaning
nnthe number of non-NULL TTFTs of one model
viv_ithe ii-th smallest TTFT
kkthe rank of the percentile

So q5_ttft_p95.sql ranks rows that have a TTFT (refusals and embeddings have NULL) and keeps the row where rn = (95 * n + 99) / 100. A WITH name AS (SELECT ...) clause (a common table expression) names that ranked set so the outer query can filter it.

A query’s values come from outside: the admin API’s URL, a CLI flag. Pasting them into the SQL text is SQL injection: tenant = 'x' OR '1'='1' reads every tenant, and a quote in a real name (o'brien is in the fixture) breaks the query. A named parameter (:tenant) is a placeholder; the value is bound separately and is always data. Your query files take :tenant, :since, :until, and :n and nothing else. A column name cannot be a parameter, which is why gw.07 picks GROUP BY columns from a fixed list.

Without an index, a query with WHERE served_model = ? reads every row: a scan. An index is a B-tree holding chosen columns of every row in sorted order, plus a pointer to the row. With an index on (served_model, ts_ms), all rows for one served model sit next to each other, sorted by time inside, so served_model = ? AND ts_ms >= ? is one jump to the first match and a walk to the end of the slice: a search. Order matters: an index on (ts_ms, served_model) sorts by time first, so the rows of one model are scattered and only the time bound can use it. The rule is equality columns first, then one range column. EXPLAIN QUERY PLAN <query> shows the plan: SCAN usage or SEARCH usage USING INDEX usage_served_ts (served_model=? AND ts_ms>?). An index costs space and slows every insert a little, so the contract indexes only what its readers filter on.

A transaction groups statements: BEGIN starts one, COMMIT makes every change visible and durable at once, ROLLBACK discards them. Outside an explicit transaction, every statement is its own transaction (autocommit). An error inside a transaction fails only that statement; the program decides whether to ROLLBACK, and a connection that closes with a transaction open rolls it back. BEGIN IMMEDIATE takes the write lock at once rather than at the first write, so two writers queue on busy_timeout instead of failing halfway.

An upsert is INSERT ... ON CONFLICT (key) DO UPDATE SET ...: insert the row, or, when the key exists, update the existing row, where excluded.col is the value that was about to be inserted. ON CONFLICT DO NOTHING ignores the row instead. changes() is the number of rows the previous statement changed, so after INSERT ... ON CONFLICT (request_id) DO NOTHING it is 1 for a new request and 0 for a replay. record.sql uses all three: in one transaction, insert the usage row (ignored on a replay), then upsert the hourly rollup only when changes() = 1.

Six requests on 2026-10-01 (12:00 is ts_ms 1790856000000), all on /v1/chat/completions:

request_idtimetenantkeymodelstatuserror_codepromptcompletionttft_ms
r112:05acmeacmekeyaaaaasmol200NULL1230180
r212:20acmeacmekeybbbbbsmol200NULL2010240
r312:40acmeacmekeyaaaaatiny429rate_limit_exceeded00NULL
r413:10acmeacmekeyaaaaasmol200NULL816200
r513:15globexglobexkeycccsmol200NULL75900
r613:50acmeacmekeybbbbbsmol503no_capacity00NULL

The window is [12:00, 14:00), so every row is in it.

  1. q1, acme’s totals. WHERE tenant = 'acme' keeps r1, r2, r3, r4, r6. Requests 55; errors 22 (r3 and r6); prompt 12+20+0+8+0=4012 + 20 + 0 + 8 + 0 = 40; completion 30+10+0+16+0=5630 + 10 + 0 + 16 + 0 = 56. Answer (5, 2, 40, 56).
  2. q2, acme by model, sorted by model: smol is r1, r2, r4, r6, so (smol, 4, 1, 40, 56); tiny is r3, so (tiny, 1, 1, 0, 0).
  3. q3, the top 2 keys by tokens (prompt plus completion): acmekeyaaaaa has 42+0+24=6642 + 0 + 24 = 66, acmekeybbbbb 30+0=3030 + 0 = 30, globexkeyccc 1212. Answer (acmekeyaaaaa, acme, 66), (acmekeybbbbb, acme, 30).
  4. q4, acme per hour. 12:05→179085600000012{:}05 \to 1790856000000, the 12:00 bucket, holds r1, r2, r3: 3 requests, 30+10+0=4030 + 10 + 0 = 40 completion tokens. The 13:00 bucket (17908596000001790859600000) holds r4, r6: 2 requests, 16 tokens.
  5. q5, p95 TTFT per model. smol’s non-NULL TTFTs are 180, 240, 200, 900, so n=4n = 4 and sorted they are 180, 200, 240, 900. k=⌊(95⋅4+99)/100⌋=⌊479/100⌋=4k = \lfloor (95 \cdot 4 + 99)/100 \rfloor = \lfloor 479/100 \rfloor = 4, so the p95 is v4=900v_4 = 900: with four samples the p95 is the maximum. tiny has no TTFT at all, so it has no row. Answer (smol, 4, 900.0).

test_hand_example runs your five queries on exactly these rows and expects these answers.

Nine files in primers/lang.11/. Each query file holds exactly one SELECT, takes its values as named parameters, and returns its columns in the order given:

FileHoldsColumns, order
q1_tenant_totals.sql:tenant’s totals in [:since, :until); zeros, not NULL, when nothing matchesrequests, errors, prompt_tokens, completion_tokens
q2_by_model.sql:tenant’s usage per model in the windowmodel, requests, errors, prompt_tokens, completion_tokens, by model
q3_top_keys.sqlthe :n keys with the most tokens (prompt plus completion) in the window, all tenantskey_id, tenant, tokens, by tokens descending, then key_id ascending
q4_hourly.sql:tenant’s requests and completion tokens per hour, hours with rows onlyhour_ms, requests, completion_tokens, by hour
q5_ttft_p95.sqlthe nearest-rank p95 of ttft_ms per model in the window, NULLs left outmodel, n, p95_ttft_ms, by model
indexes.sqlCREATE INDEX statements only, so that the query below searches an index
pragmas.sqlthe four pragmas of section 2.2
rollup.sqlCREATE TABLE IF NOT EXISTS usage_hourly (tenant, hour_ms, requests, prompt_tokens, completion_tokens), keyed by (tenant, hour_ms), hour-aligned by a CHECK
record.sqlone request: the usage insert and the usage_hourly upsert in one transaction, a replayed request_id changing nothing; parameters are the usage columns (:request_id, :ts_ms, …, :trace_id)

The query indexes.sql must serve (a per-canary panel):

SELECT worker_id, COUNT(*) AS requests, SUM(completion_tokens) AS completion_tokens
FROM usage WHERE served_model = :served_model AND ts_ms >= :since GROUP BY worker_id

The check splits record.sql at each line that ends a statement and runs the statements in order on one connection in autocommit mode, binding the row’s columns by name.

TestKINDChecksWhy it matters downstream
test_files_presentunitthe nine files existthe check names what is missing
test_hand_exampleunitsection 3, every queryyou and the check agree on each definition
test_queries_are_read_only_selects_with_named_parametersunitone statement each, read-only (an authorizer refuses writes), parameters only from :tenant :since :until :n, no tenant or time pasted ingw.07 serves them from URLs; ag.04 runs queries read-only
test_q1_tenant_totals_on_the_fixtureunit4 windows x 4 tenants (one with a quote, one with no rows)the admin API’s ungrouped answer
test_q2_by_model_on_the_fixtureunitgrouped, sorted, conditional error countgroup_by=model
test_q3_top_keys_on_the_fixtureboundaryties broken by key_id; n 1, 3, 5a stable leaderboard
test_q4_hourly_on_the_fixtureboundaryhour buckets; rows at exactly 11:00:00.000 and one ms beforehourly panels add up
test_q5_ttft_p95_on_the_fixtureunitnearest rank, NULLs excludedthe TTFT SLO of obs.03
test_index_serves_the_served_model_queryunitthe plan shows SEARCH usage USING ... (served_model=? AND ts_ms>?); contract indexes keptcanary dashboards stay fast
test_pragmas_set_wal_and_connection_settingsunitWAL seen by a second connection; NORMAL, a busy timeout, foreign keys on the firstgw.07’s data source name sets the same
test_rollup_schemaunitapplying twice works; key (tenant, hour_ms); duplicate and half-hour rows refusedconstraints catch bad writes
test_record_rolls_up_every_rowunit240 records give 240 usage rows and the right rollup; no open transaction leftthe per-request write path
test_record_replay_is_a_no_opboundary25 replays change nothingretried writes never double count
test_record_is_atomicfaulta failing rollup leaves no usage row behindcrash safety of a two-statement write
PitfallSymptomCaught by
1. SUM(x) as a totala tenant with no requests has total NULL, and the CLI prints Nonetest_q1_tenant_totals_on_the_fixture
2. error_code != NULL, or status >= 400 aloneno errors at all, or streams that failed mid-way uncountedtest_hand_example, test_q2_by_model_on_the_fixture
3. COUNT(*) or AVG in the percentilerefusals with NULL TTFT shift the rank; an average is not a p95test_q5_ttft_p95_on_the_fixture
4. ORDER BY tokens DESC with no tie-breaktwo keys with equal totals come back in either ordertest_q3_top_keys_on_the_fixture
5. upserting the rollup without checking changes()a retried write counts the request twicetest_record_replay_is_a_no_op
6. two statements without BEGIN … COMMITa failed rollup leaves the usage row behindtest_record_is_atomic
7. index (ts_ms, served_model)the plan uses only the time bound and walks every model’s rowstest_index_serves_the_served_model_query
8. synchronous set on one connection and assumed everywherethe other pooled connections run with the defaulttest_pragmas_set_wal_and_connection_settings
9. ts_ms <= :untila request at the boundary is in two hourly windowstest_q4_hourly_on_the_fixture
10. pasting a tenant into the SQL texto'brien breaks the query; x' OR '1'='1 reads everyonetest_queries_are_read_only_selects_with_named_parameters
DirectionModuleHow it uses this
Backlang.02the shell, the sqlite3 CLI, exit codes
Forwardgw.07writes usage from Go through modernc.org/sqlite with these pragmas, and serves q1 and q2 as GET /admin/v1/usage
Forwardag.04the agent’s query_usage tool runs read-only queries like these through a SQL safety gate
Forwardops.09the noisy-neighbor drill reads per-tenant usage and TTFT
Your pieceProduction equivalentWhat it addsWhere to look
SQLite in WAL modePostgreSQL, Litestreammany writers (MVCC); streaming the WAL to object storage for backupsPostgreSQL MVCC (free), Litestream (free)
the hourly rollupClickHouse materialized viewsrollups maintained by the database on every insertClickHouse materialized views (free)
nearest-rank p95percentile_cont, t-digestinterpolated and streaming percentilesPostgreSQL aggregate functions (free)