Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ Everything is in a single SQLite file (`noadd.sqlite3` by default; a legacy `noa
| `users` | Operator accounts (username, Argon2 password hash) |
| `sessions` | Active admin sessions (token, user_id, ip, user agent, timestamps) |
| `api_keys` | Programmatic API keys (BLAKE2b hash, owning user_id, `ON DELETE CASCADE`) |
| `query_stats_quarter`, `query_stats_{domain,client,upstream,metrics}_hour` | Rollups of `query_logs`: counts per quarter hour or hour and grouping key — see *Rollups* below |

`query_logs` carries five indexes, all of them shaped by the statistics queries:

Expand All @@ -260,6 +261,18 @@ The upstream index is partial because blocked and cached answers never reach an

Indexes are not free here. On that same 103 MiB database `dbstat` attributes 20 MiB to `(domain, timestamp)`, 18 MiB to `(client_ip, doh_token, timestamp)`, 9 MiB to the metrics index and 7 MiB to `timestamp` — the two composites added for statistics cost about a quarter of the file. (Those are the figures from before version 15 replaced the client index.) Measuring an index by the file-size delta of `CREATE INDEX` understates it whenever the database is carrying a freelist, since the new pages come out of that first; `dbstat` reports the real figure.

### Rollups

Every index above is still read one entry per logged query. With the default seven-day retention the Statistics page's windows, and the dashboard's 30-day summary, span the whole table, so no index choice can take those readings below the size of the index they read — 9 475 pages for the summary on a 1.48 M-row database, every dashboard tick. Version 16 adds five rollup tables in which one row stands for every query sharing a key within a unit of time: `query_stats_quarter` (blocked, cached; count and summed response time per quarter hour), and per hour `query_stats_domain_hour`, `query_stats_client_hour`, `query_stats_upstream_hour` (count and summed response time) and `query_stats_metrics_hour` (the grain the outcome, query-type and latency folds read). They are `WITHOUT ROWID`, with the unit first in the key, so a window is one range and the newest unit is where every write lands. On that database they total 3 631 pages of a 111 282-page file.

The quarter hour is there because it is the finest bucket any chart draws and the unit every UTC offset in use is a whole number of; nothing else needs finer than the hour. `doh_token` is stored as `''` for plain DNS, since a key column cannot hold `NULL`.

**The rollups must always equal a recount of `query_logs`**, because a reader folding them answers for the table. Inserts keep them there through an `AFTER INSERT` trigger (`query_logs_maintain_stats`) rather than through the logger, so rows that reach the table any other way — the e2e fixtures write theirs with the `sqlite3` CLI — are counted too. Replaying the same 1.48 M queries in the logger's 500-row batches, the trigger writes 886 480 pages against 885 858 for one grouped upsert per batch and 846 079 with no rollups at all.

Deletes are not a trigger. A `DELETE` trigger would unwind a prune row by row and turn off SQLite's truncate optimisation for Clear All, so both do it in their own transaction instead: Clear All empties the five tables, and `prune_logs_before` calls `unwind_stats_rollups` before its delete. That drops whole units before the cutoff and, for the one quarter and one hour the cutoff falls inside, recounts the rows about to go and subtracts them — so the prune keeps its exact cutoff rather than rounding retention to the hour. Pruning a day from that database writes 5 151 pages and misses 13 971, against 5 020 and 12 282 without rollups. `rollups_follow_every_write_that_changes_query_logs` (`src/db.rs`) is the guard: it compares every table with its recount after batches, a direct SQL insert, prunes inside and on a unit boundary, and Clear All.

Version 16 fills the rollups from the rows already logged, which on that database reads 101 420 pages and writes 3 714. The fill replaces rather than adds, so a migration interrupted before `user_version` moved is safe to run again. Nothing reads the rollups yet; the dashboard and the Statistics page move onto them in later changes.

### Measuring these queries

Index work here is measured in **page misses** — `SQLITE_DBSTATUS_CACHE_MISS`, the 4 KiB database pages SQLite has to fetch from the file — and not in milliseconds. Development happens on an SSD and the appliance runs off an SD card, where the same page count costs orders of magnitude more; a query that reads the whole table can look free on one machine and take seconds on the other. `tests/stats_page_miss_bench.rs` reports the figure against a real database and `tests/stats_page_miss_test.rs` asserts the properties behind it; `tests/dashboard_page_miss_bench.rs` does the same for the dashboard's first response and its recurring event-stream tick, and `tests/logs_page_miss_bench.rs` for every filter the query log offers, with values drawn from the database under test. All three take `BENCH_DB` and a `BENCH_NOW` that pins the clock, so a copy older than its windows still measures the traffic it holds; `tests/stats_parallel_bench.rs` is the wall-clock companion, and is the one to distrust when the two disagree.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Statistics adds the conventions for a page whose readings sit in a **chosen wind
- **This page is measured in page misses, not milliseconds.** Development is on an SSD and the appliance runs off an SD card, so a duration measured here says nothing about a Raspberry Pi; the pages a query fetches from the file are the same on both. `cargo nextest run --release --no-capture --run-ignored only stats_page_miss` with `BENCH_DB` pointed at a copy of a real database reports them; `dashboard_page_miss` and `logs_page_miss` do the same for the dashboard (first response and tick) and the query log (every filter), and `BENCH_NOW` pins the clock on a copy older than its windows. A wall-clock reading is the thing to distrust when the two disagree — it is what left the outcome breakdown scanning the whole table through version 11.
- **One scan per index, not one per reading.** `stats_scan_since` and `traffic_lists_since` (`src/db.rs`) are the page's two scans — the second answering top domains, the distinct-domain count and top clients from one grouping of `(domain, client_ip, doh_token)` — and every reading on it — the charts included — is folded out of one of them; `compute_range_stats` (`src/admin/stats.rs`) is what the page calls. `stats_scan_since` streams its rows and folds them in Rust rather than grouping in SQL: a grain carrying both the quarter hour and `response_ms` approaches a group per row, which would be a temp b-tree the size of the window. The single-purpose functions `/api/stats/*` uses are statements of their own — adding a seventh reading to the page means folding it out of one of those two scans, not adding a statement.
- **A total nobody can count cheaply is maintained, not counted.** `query_logs`' row count lives in `settings` (`query_log_count`), moved by the insert batch, the prune and Clear All inside their own transactions — `SELECT COUNT(*)` walks an index end to end, and both the Database Health card and the query log's pager (whenever no filter is applied, via `count_logs`) ask on every load. A fourth write path to `query_logs` means a fourth `bump_log_count`, not a fourth reader.
- **The same holds for the statistics rollups** (`query_stats_*`, see ARCHITECTURE.md *Rollups*), which must always equal a recount of `query_logs`. Inserts are covered by the `query_logs_maintain_stats` trigger whatever writes them; a new path that *deletes* from `query_logs` has to unwind them in its own transaction, as `prune_logs_before` (`unwind_stats_rollups`) and Clear All do.
- Four bar lists in one template share **one askama macro** (`templates/_macros.html`); `{% call … %}` needs a matching `{% endcall %}` in askama 0.16, and `{% include %}` cannot see a loop variable at all.

Account adds the conventions for **actions that need a password proof**:
Expand Down
Loading