Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s
418 lines
20 KiB
Markdown
418 lines
20 KiB
Markdown
# Milestone 36: Overview performance — combined endpoint, projections, cache
|
||
|
||
Replace the five per-panel stats endpoints with one `GET /api/overview` served
|
||
from materialized projections in `querylog.db` plus an in-memory response
|
||
cache, so Overview cost stops growing with query-log size.
|
||
|
||
## Motivation (measured)
|
||
|
||
Today each Overview load runs five separate scans of every raw row in the
|
||
window, serialized on `WebState.querylog_lock`, re-polled every 30 s. Measured
|
||
x86 ReleaseSafe (bench at scratchpad `statsbench2/`, production-like skew;
|
||
Pi ≈ 3–3.5× slower):
|
||
|
||
| Rows | 30d, five scans (today) | 30d, one combined scan | 30d, projections |
|
||
|-----:|------------------------:|-----------------------:|-----------------:|
|
||
| 1M | ~2.2 s | 264 ms | 41 ms |
|
||
| 3M | ~6.6 s | 793 ms | 38 ms |
|
||
| 5M | ~11.8 s | 1,346 ms | 40 ms |
|
||
|
||
Projection maintenance costs +10% per 100-row insert batch, and ~1.5 MB of
|
||
disk in the bench — a size bounded by retained buckets × distinct
|
||
client/type/route keys, independent of raw query volume. Production is on a ~100k rows/day growth
|
||
curve (≈3M rows at 30-day retention), so the projection path is the design
|
||
target, not a contingency. Both computations were cross-checked for identical
|
||
output in the bench.
|
||
|
||
Design ruling (owner + Codex consultation, 2026-08-27): stay on SQLite;
|
||
projections live in the same file as the raw rows and are updated in the same
|
||
transaction, so SQLite's transaction is the coherence mechanism — no second
|
||
file, no epoch protocol. The DDL change re-fingerprints `querylog.db`; the
|
||
existing rename-aside path handles old files (one-time history reset,
|
||
disclosed in the changelog). No backfill migration.
|
||
|
||
## Sessions
|
||
|
||
Four sessions. A first. B and C after A, in parallel (disjoint files). D after B.
|
||
|
||
- A: storage — projection schema, writer maintenance, retention, new read path.
|
||
A does NOT delete the five existing aggregate functions — `stats.zig` still
|
||
calls them until B lands, and A must leave `zig build test` green.
|
||
- B: web — `/api/overview` handler, response cache, removal of the five old
|
||
endpoints, OpenAPI/contract regeneration.
|
||
- C: admin — one overview query, types, component/data plumbing, tests.
|
||
- D: storage cleanup — delete the five now-unreferenced aggregate functions.
|
||
|
||
---
|
||
|
||
## Session A: storage
|
||
|
||
### A.1 Schema (src/storage/querylog_schema.zig)
|
||
|
||
Append four projection tables to `ddl`. Grain: 30-minute buckets, `bucket` =
|
||
floor-to-grid of the row timestamp: `@divFloor(timestamp, 1800) * 1800` in Zig
|
||
and the equivalent floor semantics in any SQL (SQLite integer `/` truncates
|
||
toward zero, which differs on negative timestamps — use floor everywhere, as
|
||
`window()` does). 1800 divides every serving
|
||
width ≥ 30 min (1800, 3600, 21600), which is what makes one grain serve the
|
||
24h, 7d and 30d windows exactly. The 1h window (60 s buckets) is NOT served
|
||
from projections (A.4).
|
||
|
||
```sql
|
||
CREATE TABLE bucket_totals (
|
||
bucket INTEGER PRIMARY KEY,
|
||
queries INTEGER NOT NULL,
|
||
blocked INTEGER NOT NULL,
|
||
cached INTEGER NOT NULL,
|
||
rt_sum INTEGER NOT NULL, -- sum(response_time_us) over timed rows
|
||
rt_count INTEGER NOT NULL -- count(response_time_us)
|
||
) WITHOUT ROWID;
|
||
|
||
CREATE TABLE bucket_clients (
|
||
bucket INTEGER NOT NULL,
|
||
client_ip TEXT NOT NULL,
|
||
queries INTEGER NOT NULL,
|
||
PRIMARY KEY (bucket, client_ip)
|
||
) WITHOUT ROWID;
|
||
|
||
CREATE TABLE bucket_types (
|
||
bucket INTEGER NOT NULL,
|
||
qtype INTEGER NOT NULL, -- -1 encodes a NULL qtype, losslessly
|
||
count INTEGER NOT NULL,
|
||
PRIMARY KEY (bucket, qtype)
|
||
) WITHOUT ROWID;
|
||
|
||
CREATE TABLE bucket_routes (
|
||
bucket INTEGER NOT NULL,
|
||
route_kind TEXT NOT NULL,
|
||
source_present INTEGER NOT NULL, -- 0: source NULL; 1: source = source_text
|
||
source_text TEXT NOT NULL, -- '' when source_present = 0
|
||
count INTEGER NOT NULL,
|
||
PRIMARY KEY (bucket, route_kind, source_present, source_text),
|
||
CHECK (source_present IN (0, 1)),
|
||
CHECK (source_present = 1 OR source_text = '')
|
||
) WITHOUT ROWID;
|
||
```
|
||
|
||
Column semantics match the existing aggregates exactly: `blocked` counts
|
||
`blocked <> 0`; `cached` counts `cache_hit = 1`; routes' `source` is the
|
||
existing CASE (`upstream` rows → `upstream`, `forward_zone` rows →
|
||
`forward_zone`, else NULL). The fingerprint moves automatically; do not touch
|
||
the fingerprint machinery.
|
||
|
||
### A.2 Writer maintenance (src/storage/repositories/queries_repo.zig)
|
||
|
||
`BatchWriter.writeBatch` updates all four projections inside the same
|
||
transaction that inserts the raw rows:
|
||
|
||
- Aggregate the batch in Zig first, producing per-key deltas; then one UPSERT
|
||
per touched key:
|
||
`INSERT ... ON CONFLICT(...) DO UPDATE SET queries = queries + excluded.queries, ...`.
|
||
The aggregation must accept any slice length — `writeBatch`'s API does not
|
||
enforce the logger's 100-row batching, so no fixed-size arrays sized to it.
|
||
`BatchWriter` currently owns no allocator: `init` gains one, owned for the
|
||
writer's life, used only for the per-batch delta maps; scratch is freed (or
|
||
a retained map cleared) at the end of every `writeBatch`, and
|
||
`error.OutOfMemory` fails the batch before the transaction opens — no
|
||
hidden global allocator, no implicit size cap, no quadratic rescanning.
|
||
- No per-row SQL, no triggers.
|
||
- Failure contract: any failed projection statement rolls the whole
|
||
transaction back — raw rows and projections together — resets every
|
||
projection statement, and leaves the writer usable for the next batch
|
||
(`resetAll` discipline as for the raw statements today). Fault-injection
|
||
acceptance: a batch whose projection update fails leaves the database
|
||
unchanged, and the next batch succeeds.
|
||
- The bench measured this at 0.39 ms vs 0.34 ms per batch — acceptance is
|
||
correctness, not speed.
|
||
|
||
### A.3 Retention (src/storage/repositories/queries_repo.zig, prune path)
|
||
|
||
In the same transaction as `pruneOlderThan(cutoff)`'s raw delete:
|
||
|
||
1. Delete projection rows with `bucket < floor(cutoff / 1800) * 1800` from all
|
||
four tables.
|
||
2. If `cutoff` is not on a bucket boundary, recompute the straddling bucket
|
||
(`floor(cutoff/1800)*1800`) from the remaining raw rows and replace its
|
||
projection rows in all four tables. Never approximate.
|
||
|
||
Failure atomicity: a failure during the projection delete or the
|
||
straddling-bucket replacement rolls back the raw delete, the watermark
|
||
advance and every projection change together — one transaction, tested by
|
||
fault injection.
|
||
|
||
Implementation note (Session A, recorded post-build): the bucket_totals
|
||
recompute carries `HAVING count(*) > 0` — a bare SQL aggregate always yields
|
||
one row, and an emptied straddling bucket must disappear, not persist as
|
||
zeros.
|
||
|
||
### A.4 Read path (src/storage/repositories/queries_repo.zig)
|
||
|
||
One function producing the whole Overview payload for a window, from one
|
||
already-open read transaction (the caller owns transaction + lock, as today):
|
||
|
||
```zig
|
||
pub const Overview = struct {
|
||
totals: StatsTotals,
|
||
buckets: []const Bucket, // bucket_count entries, zero-filled
|
||
clients: ClientsBreakdown, // top-8 + other, as today
|
||
types: []const TypeCount, // sorted as stats_types_sql sorts
|
||
routes: []const RouteCount, // sorted as stats_routes_sql sorts
|
||
};
|
||
|
||
pub fn overview(
|
||
database: *db.Db,
|
||
arena: Allocator,
|
||
since: i64,
|
||
bucket_seconds: u32,
|
||
bucket_count: u32,
|
||
) db.Error!Overview
|
||
```
|
||
|
||
Storage owns these scalars — no import of any web module. `until` is derived
|
||
as `since + bucket_seconds * bucket_count` with the same overflow checks as
|
||
`timeseries`. Preconditions, checked before path selection and tested:
|
||
`bucket_seconds != 0` and `bucket_count != 0` (else `error.Misuse`, matching
|
||
the existing clients contract); on the projection path
|
||
(`bucket_seconds >= 1800`) additionally `since` a multiple of 1800 and
|
||
`bucket_seconds % 1800 == 0`, else `error.Misuse`. The handler's `window()`
|
||
guarantees all of them.
|
||
|
||
Two implementations behind one entry point, chosen by `bucket_seconds`:
|
||
|
||
- `bucket_seconds >= 1800` (24h, 7d, 30d): read the four projection tables
|
||
over `[since, until)`, aggregating 30-min rows up to the serving width in
|
||
Zig. `distinct_clients` comes from grouping `bucket_clients` by `client_ip`
|
||
over the window — never from summing per-bucket counts.
|
||
`avg_response_time_us` = `sum(rt_sum) / sum(rt_count)`, null when
|
||
`rt_count` sums to 0. Top-8 clients ranked by window total desc, ties by
|
||
`client_ip` asc (BINARY), residual summed into `other` — identical cut
|
||
semantics to `statsClients`.
|
||
- `bucket_seconds < 1800` (1h): one single pass over the raw rows in the
|
||
window (one SELECT of the needed columns, stepped once), aggregating
|
||
everything in Zig. Memory bound: O(distinct clients + distinct qtypes +
|
||
distinct routes) in the window — explicitly permitted; this is a household
|
||
LAN and the same bound the arena-returning aggregates already carry. This
|
||
replaces today's five scans and the clients rank+bucket double scan. Same
|
||
output contracts.
|
||
|
||
Sort orders and tie-breaks must reproduce the existing SQL orderings exactly
|
||
(types: count desc, null last within tie, qtype asc; routes: count desc,
|
||
route_kind asc, null source last, source asc) — the goldens' byte-stability
|
||
argument carries over. Note: `RouteKind`'s enum declaration order is not
|
||
alphabetical; "route_kind asc" means the stored text's byte order, so any Zig
|
||
comparator orders by `@tagName` bytes, never by enum ordinal (Session A's
|
||
accumulator already does; mutation-tested).
|
||
|
||
### A.5 Acceptance criteria
|
||
|
||
- [ ] `zig build test` green.
|
||
- [ ] Property test: after an arbitrary interleaving of batches and prunes
|
||
(including a prune cutoff off the bucket grid), every projection table
|
||
equals a from-scratch recomputation from `query_log`.
|
||
- [ ] Equivalence test: `overview()` output (both paths) equals a test-only
|
||
oracle over the same window on the same data — including empty windows,
|
||
NULL qtype, NULL source on an `upstream` row, ties in ranking, and a
|
||
window whose last bucket is in progress. The oracle is a copy of the
|
||
five existing SQL aggregates living in the test file, so it survives
|
||
Session D's deletion of the production functions.
|
||
- [ ] Fingerprint test updated (table/index count assertions in
|
||
querylog_schema tests).
|
||
|
||
---
|
||
|
||
## Session B: web
|
||
|
||
### B.1 Endpoint (src/web/handlers/overview.zig, replacing stats.zig's five)
|
||
|
||
`GET /api/overview?period=1h|24h|30d|7d` (same grammar, default 24h, same 400
|
||
text). One read transaction under `WebState.querylog_lock` covering the
|
||
aggregate and `coverage.read` — one snapshot, no cross-panel skew. Response:
|
||
|
||
```json
|
||
{
|
||
"period": "24h", "since": ..., "until": ..., "bucket_seconds": 1800,
|
||
"totals": { "queries": n, "blocked": n, "clients": n, "avg_response_time_us": n|null },
|
||
"buckets": [ { "ts": ..., "queries": n, "blocked": n, "cached": n }, ... ],
|
||
"clients": [ { "client": "ip", "buckets": [n, ...] }, ... ],
|
||
"other": [n, ...],
|
||
"types": [ { "qtype": n|null, "count": n }, ... ],
|
||
"routes": [ { "route": "...", "source": "..."|null, "count": n }, ... ],
|
||
"coverage": { ... }
|
||
}
|
||
```
|
||
|
||
Field shapes and semantics are exactly today's five bodies merged; `Period`,
|
||
`window()`, `max_buckets` move to (or stay importable from) the new handler.
|
||
503 when the query log is unavailable; 500 logging unchanged. Route metadata
|
||
identical to the removed endpoints: same authentication (`.session`), same
|
||
rate-limit class (`.counted`), same authority policy (`.read`).
|
||
|
||
Remove `GET /api/stats`, `/api/stats/timeseries`, `/api/stats/types`,
|
||
`/api/stats/routes`, `/api/stats/clients` and their routes.
|
||
|
||
Contract surface (B owns all of it): add the new path and schema to the
|
||
OpenAPI document, remove the five old operations and their schemas, update
|
||
every drift guard that lists them, add a contract sample for
|
||
`/api/overview`, and regenerate `admin/src/lib/contractSamples.gen.ts`
|
||
(reserved for B — Session C must not touch it). Update the API listings in
|
||
`docs/` and `PLAN.md` that name the five endpoints or the querylog layout.
|
||
|
||
### B.2 Response cache (src/web/server.zig WebState + overview.zig)
|
||
|
||
Per-period cached response body, invalidated by data change or window roll.
|
||
Key: `(period, window.until, data_version)` where `data_version` is `PRAGMA
|
||
data_version` on the web task's connection (it changes when any other
|
||
connection — logger, retention — commits).
|
||
|
||
The entire cache decision happens under `querylog_lock`; nothing touches the
|
||
shared connection or the slots outside it. Exact sequence per request:
|
||
|
||
1. Acquire `querylog_lock` — ONCE. `server.QuerylogRead.open` acquires this
|
||
lock itself, so the overview handler must not call it after step 1: B
|
||
refactors the scope into a lock-owning wrapper plus a
|
||
locked-caller variant (for example `QuerylogRead.openLocked`, documented
|
||
as requiring the lock), and the overview path uses the locked-caller
|
||
variant for step 4. A literal "lock, then QuerylogRead.open" deadlocks.
|
||
2. Sample `PRAGMA data_version` (inside the lock — the shared connection may
|
||
otherwise have a foreign transaction open, and the slots need the mutual
|
||
exclusion anyway).
|
||
3. Hit (`slot.period == period and slot.until == window.until and
|
||
slot.data_version == sampled`): copy the stored bytes into the request
|
||
arena, release the lock, respond. The copy is what makes a concurrent
|
||
rebuild's free-and-replace safe.
|
||
4. Miss: open the read transaction, build the body, commit. Publish to the
|
||
slot ONLY after a successful commit, keyed by the version sampled in
|
||
step 2 (a commit landing during the build bumps `data_version`, so the
|
||
next request rebuilds — stale-under-new-key is impossible). A failed
|
||
commit or build publishes nothing and responds 500 as today.
|
||
5. Copy to the request arena, release the lock, write the socket. The lock
|
||
never spans a socket write (existing discipline).
|
||
|
||
Because the check happens only under the lock, `querylog_lock` is the
|
||
single-flight: a second request for the same key waits and then hits.
|
||
|
||
Storage: one slot per period (4 slots) in `WebState`; body bytes allocated
|
||
from `WebState.gpa`, replaced on rebuild (free old, install new), freed in
|
||
`deinit`. No capacity limit beyond the allocator — a body is bounded by the
|
||
fixed bucket counts plus the household client/type/route cardinality.
|
||
|
||
No adaptive polling and no combined-endpoint staging: with projections + this
|
||
cache a rebuild is ~40 ms x86 / ~0.13 s Pi, so the admin's existing 30 s
|
||
cadence is fine.
|
||
|
||
### B.3 Acceptance criteria
|
||
|
||
- [ ] `zig build test` green; handler tests ported from stats.zig (period
|
||
grammar, window math, one-snapshot behavior) plus: cache hit returns
|
||
byte-identical body; a logger commit (data_version bump) invalidates;
|
||
a window roll invalidates; a retention prune committed through another
|
||
connection invalidates (both the aggregates and the cached
|
||
`coverage.available_since` are replaced); a failed read-transaction
|
||
commit neither installs nor replaces a cache entry.
|
||
- [ ] `curl /api/overview?period=30d` on a seeded scratch instance returns all
|
||
panels consistent (breakdowns sum to totals on a quiet database).
|
||
- [ ] The five old routes return 404.
|
||
|
||
---
|
||
|
||
## Session C: admin
|
||
|
||
### C.1 Data layer
|
||
|
||
- `admin/src/lib/types.ts`: one `Overview` type mirroring B.1; remove the five
|
||
per-panel response types.
|
||
- `admin/src/lib/api.ts`: `getOverview(period)`; remove the five getters.
|
||
- `admin/src/lib/queries.ts`: `overviewQuery(period)` with
|
||
`refetchInterval: 30_000` and key `["overview", period]`; remove the five
|
||
stats query factories and their keys.
|
||
|
||
### C.2 Overview page
|
||
|
||
`admin/src/features/overview/overviewWindow.ts` and the chart components
|
||
consume the single query: one `useQuery` where five ran in parallel. Loading,
|
||
error and coverage handling collapse to one page-level surface (one spinner
|
||
state, one error state for the whole Overview); the per-panel shells,
|
||
layout, copy, chart dimensions and accessibility attributes stay exactly as
|
||
they are. Chart components (TimeseriesChart, ClientChart, Donut) keep their
|
||
props — adapt the mapping layer, not the charts. C must not touch
|
||
`contractSamples.gen.ts` (B owns its regeneration).
|
||
|
||
### C.3 Acceptance criteria
|
||
|
||
- [ ] `npm test` green in admin/ (mock the one endpoint; port the five-query
|
||
tests).
|
||
- [ ] `npm run typecheck` green — the build alone does not run tsc. B and C
|
||
are file-disjoint but type-coupled through `contractSamples.gen.ts`:
|
||
C's typecheck/test/build gates run (or re-run) AFTER B has regenerated
|
||
that file. Implementation may proceed in parallel; the green gate is
|
||
sequenced.
|
||
- [ ] `npm run build` green; bundle-size assertion still passes.
|
||
- [ ] Manual: scratch instance renders all Overview panels from the new
|
||
endpoint on all four periods.
|
||
|
||
---
|
||
|
||
## Session D: storage cleanup
|
||
|
||
After B is merged and green: delete `statsTotals`, `timeseries`, `statsTypes`,
|
||
`statsRoutes`, `statsClients` and their SQL constants from
|
||
`queries_repo.zig` — nothing references them once `stats.zig` is gone. The
|
||
test-only oracle from A.5 stays. Acceptance: `zig build test` green, no dead
|
||
stats SQL remains in production code.
|
||
|
||
---
|
||
|
||
## Module layout
|
||
|
||
- `src/web/handlers/overview.zig` — new; replaces `src/web/handlers/stats.zig`
|
||
(deleted by B).
|
||
- `src/storage/querylog_schema.zig` — projection DDL appended.
|
||
- `src/storage/repositories/queries_repo.zig` — writer maintenance, retention
|
||
integration, `overview()` read path (A); five old aggregates deleted (D).
|
||
- Admin files per C.1/C.2.
|
||
|
||
## File ownership
|
||
|
||
- A: `src/storage/*` (old aggregates left in place), plus the mechanical
|
||
allocator-plumbing at every `BatchWriter.init` call site outside storage
|
||
(`src/web/handlers/*`, `src/web/web_integration_test.zig`, any test using
|
||
the writer) — A runs before B, so this is sequential, not shared,
|
||
ownership; A's gate is the full `zig build test`.
|
||
- B: `src/web/*`, plus: `src/app.zig` (cache cleanup at the composition
|
||
root), `src/tests.zig` (handler import swap), the OpenAPI document and its
|
||
drift guards, contract samples including
|
||
`admin/src/lib/contractSamples.gen.ts`, `docs/**` API listings, `PLAN.md`
|
||
stale sections, and the Overview/API sections of `specs/ui-redesign.md`
|
||
(which still mandates the five endpoints and must be amended, not obeyed).
|
||
- C: `admin/*` EXCEPT `admin/src/lib/contractSamples.gen.ts`.
|
||
- D: `src/storage/repositories/queries_repo.zig` (sequential, after B).
|
||
- Orchestrator: `CHANGELOG.md` (hand-written, per release process).
|
||
B and C run in parallel; the one shared-tree exception above is reserved to B.
|
||
|
||
## Acceptance criteria (milestone complete)
|
||
|
||
- [ ] `zig build test` and `zig build test -Dintegration` green (the
|
||
integration suite carries the live route walk, contract-sample
|
||
comparison, concurrent querylog reads and OpenAPI guards); admin
|
||
`npm test`/`typecheck`/`build` green.
|
||
- [ ] Scratch-instance smoke: seeded data + live digs; Overview correct on all
|
||
periods; old endpoints gone.
|
||
- [ ] Changelog discloses: schema change resets query history (rename-aside),
|
||
five endpoints replaced by `/api/overview`.
|
||
- [ ] Release gate (`zig build cut` fingerprint check) satisfied.
|
||
|
||
## Anti-requirements
|
||
|
||
- No second database file, no epoch/validity protocol, no ATTACH.
|
||
- No backfill migration, no rebuild command, no catch-up cursor — projections
|
||
are born with the file and maintained transactionally; that is the whole
|
||
coherence story.
|
||
- No connection pool, no adaptive polling, no DuckDB.
|
||
- No new indexes on `query_log`, no triggers, no per-row projection SQL.
|
||
- Do not change the 1h/24h/7d/30d period grammar, bucket widths or counts.
|
||
- No HTTP-level caching of any kind: no ETag, no `Cache-Control`, no
|
||
stale-while-revalidate, no background refresh, and never cache a 500/503
|
||
body. The cache is exactly the in-process design of B.2.
|
||
- No visual redesign, no chart-prop changes, no cache configuration knobs,
|
||
no cache metrics. This milestone changes data acquisition and storage only.
|