Files
nxdns/specs/milestone-6.md
T

731 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Milestone 6: Cache, Rate Limiter, Query Logger, Retention, Disk Monitor, Log Sink
PLAN Phase 6. Build every runtime component between the resolver core and the web layer: the TTL
cache (§8), the per-client DNS rate limiter (§10), the async query logger with backpressure
(§11.4), retention (§11.5), the disk monitor and the rotating log sink with upstream-error dedup
(§11.6). Nothing is wired into the query path — composition is Phase 7. Every component is built
complete, tested standalone, and exposed through accessors Phase 7 and Phase 8 will consume.
## Sessions
Eight sessions. Wave 1 starts five in parallel: S1 (cache), S2 (rate limiter), S3 (queries repo),
S5 (statfs + disk monitor), S6 (log sink). Wave 2 starts when S3 verifies: S4 (logger) and S7
(retention) in parallel — both consume S3's repo and nothing of each other. S8 (integration) runs
last. The orchestrator — not any session — wires `src/tests.zig` and installs the log sink's
`std_options` hook in `src/main.zig`. (`validate.zig:221` already rejects
`rate_window_seconds` outside 1`max_rate_window_seconds`; no rule is missing.)
Every later session is written against **this spec**, not against another session's source. Where
this spec and a built file disagree after a session verifies, the built file wins and the
orchestrator records the difference in "## As built".
## Design invariants
- **Pure core.** `src/cache/dns_cache.zig` and `src/server/rate_limiter.zig` take timestamps as
parameters and hold no `std.Io`, no clock, no socket. `std.Io` appears only in
`src/storage/logger.zig`, `src/storage/disk_monitor.zig`, `src/storage/retention.zig`,
`src/platform/logging.zig`, `src/platform/statfs.zig` (its test), and the S8 test file.
- **Neither the cache nor the limiter is thread-safe.** Phase 7 decides the locking when it wires
them. Both files state that in their module doc comment.
- **No allocation on the hit path.** `DnsCache.get` and `RateLimiter.check` allocate nothing.
`DnsCache.put` allocates exactly one buffer for the stored response bytes.
- **Every failure mode is counted.** Dropped log entries, refused queries, evictions, expired
entries, deduplicated log lines, failed disk samples — a counter each, readable by Phase 8.
- **No `std.log.err` in any file this milestone writes.** A condition returned as a typed error
logs at `warn` at most. `err` stays reserved for swallowed failures, and this milestone swallows
none.
- **Timestamps follow the house style** (health.zig): `std.Io.Timestamp` parameters, arithmetic on
`.nanoseconds`, wall seconds for DB rows via `Clock.real.now(io).toSeconds()`, monotonic
decisions on `.awake`, long schedules on `.boot`.
- **Background loops follow `manager.runScheduler`'s shape**: `Cancelable!void`, non-cancel
failures log at `warn` and the loop continues, cancellation returns. Phase 7 starts them.
## Resolved PLAN ambiguities (rulings for this milestone)
1. **Client-row pruning is Phase 7.** §7.2's "retention drops `hand_edited=0` clients" deletes
from `config.db`, which §3.6 walls off from retention churn. The rows it prunes are created by
auto-materialization, which is Phase 7; the pruning ships with it. §11.5 retention here touches
`querylog.db` only.
2. **Free space drives the thresholds; sizes are gauges.** The monitor samples `statvfs` free
bytes for the warn/critical decision and records DB and log-directory sizes as gauges for
Phase 8's health payload. Both are sampled in the same pass.
3. **Flush tuning is comptime.** `flush_batch = 100`, `flush_interval_ms = 100` as public
constants in `logger.zig`. §12.1 defines no config keys for them and a household deployment
never tunes them.
4. **The SSE seam is the transformed entry.** Privacy transforms run at enqueue, so every entry in
the queue is already presentable. Phase 8 subscribes by wrapping `Logger.log` — no hook, no
callback, no dead vtable.
5. **The positive-TTL sanity ceiling is 86 400 seconds**, the same `max_ttl_seconds` constant
`validate.zig:196` already enforces on `blocking.ttl` and `cache.negative_ttl_max`. Comptime
constant in `dns_cache.zig` with a comment naming that origin. No new config key.
6. **`negative_ttl_max = 0` disables negative caching** (the response is not stored), not the cap.
`validate.zig` already caps the field at `max_ttl_seconds`, so "no cap" would be expressible as
86 400 anyway; "0 disables" only has meaning as an off switch.
7. **`cache.size` counts entries**, not bytes. 10 000 entries at the observed household response
sizes is single-digit megabytes; a byte budget would add config surface for nothing.
8. **The DNS limiter is a fixed window.** §10 reserves the token bucket for the API (Phase 8);
"1000 per 60 s" plus "stale-key windowed sweep" describes a counter that resets each window.
A fixed window admits at most 2× the limit across a window boundary, which is acceptable for
abuse protection at household scale and costs half the state of a sliding window.
9. **Upstream-error dedup lives in the log sink.** It is a property of how lines reach the
operator, not of the upstream module: `platform/logging.zig` deduplicates warn-and-above lines
from the upstream scopes at one per key per minute. No milestone-3 file is edited.
10. **Schedules: retention daily, checkpoint after every prune, VACUUM every 7th pass.** §11.5
names no interval. Daily matches the granularity of `retention_days`; `wal_checkpoint
(TRUNCATE)` after each prune keeps the WAL from monopolizing the disk the monitor watches; a
nightly full VACUUM would rewrite the whole file on an SD card every day, so VACUUM runs on
every 7th retention pass.
## Verified 0.16.0 stdlib facts (see specs/research/zig-0.16-api-notes.md, "Phase 6 verifications")
- `std.Io.Queue(Elem)` (Io.zig:2184): bounded ring over a caller array. `put(q, io, elems, 0)`
never blocks and returns 0 when full (Io.zig:2218); `getOne` blocks; `close(io)` unblocks it
with `error.Closed`. Elements are copied raw (Io.zig:2189) — **queue elements must be
self-contained values**, no slices into caller memory.
- `std.Io.Condition` has **no `timedWait`**. The timed primitive is `Event.waitTimeout`
(Io.zig:1827). The logger's flush interval uses `Select` racing `getOne` against a
`Clock.Duration` sleep, the `fetchWithin` pattern from manager.zig:722.
- **No statvfs in std.** `src/platform/statfs.zig` declares `extern fn statvfs` against libc
(every build already links libc for sqlite; glibc and musl share the 64-bit POSIX layout).
Free bytes = `f_bavail * f_frsize`.
- **No append mode.** Log sink append: `openFile(io, path, .{ .mode = .write_only })`,
`file.writer(io, &buf)`, `w.seekTo(try file.length(io))`. `Dir.rename` + `Dir.deleteFile`
rotate. One owner per writer — the sink serializes behind its own mutex.
- **No fixed-capacity hash map in std.** Bounded structures use a fixed slot array plus
`std.HashMapUnmanaged` from key to slot index with `ensureTotalCapacity` at init; the CLOCK
hand walks the slot array, never the map. Map modification invalidates live iterators
(hash_map.zig:496).
- `packet.decrementTtls(bytes, elapsed_seconds) ParseError!?u32` (packet.zig:234) ages every
record in place, skips OPT, validates before writing, returns the smallest resulting TTL; on
error the buffer is partly aged and must be discarded. With `elapsed = 0` it is a pure
validate-and-min-TTL pass. `packet.setId` (packet.zig:202) rewrites the transaction ID.
- `db.zig`: `Stmt.reset` clears bindings — rebind everything each iteration. `Db`/`Stmt` are not
thread-safe; the logger's writer task owns its `Db` handle exclusively. A batch is one
`Tx.begin` + reset/rebind/step loop + `commit`.
- `address.NetAddress.Key` is `[17]u8` covering both families; `fromIp` folds IPv4-mapped IPv6 to
`.ip4`, so one client is one key.
---
## Session S1: DNS cache — `src/cache/dns_cache.zig`
New directory `src/cache/`. Imports: `std`, `../dns/packet.zig`, `../dns/types.zig`,
`../dns/record.zig`, `../config/model.zig`. No `std.Io`.
### S1.1 Key
```zig
/// qname (lowercased ASCII, no trailing dot) ++ 0x00 ++ qtype BE ++ qclass BE ++ do byte
/// ++ ecs_len byte ++ ecs bytes. ECS participates only when the caller passes it (Phase 7
/// passes the forwarded subnet only under ecs_mode=forward, PLAN §8).
pub const max_key_len = 255 + 1 + 2 + 2 + 1 + 1 + 40;
pub fn buildKey(buf: *[max_key_len]u8, qname: []const u8, qtype: u16, qclass: u16,
do_bit: bool, ecs: ?[]const u8) []const u8;
```
`buildKey` lowercases `A``Z` while copying and strips one trailing dot. Asserts `qname.len <=
255` and `ecs.?.len <= 40` — the caller has already validated both through the packet parser.
### S1.2 Classification
```zig
pub const Class = struct { ttl_seconds: u32, negative: bool };
/// Decides whether a response is cacheable and for how long. Null means do not cache.
pub fn classify(response: []const u8, negative_ttl_max: u32) ?Class;
```
Rules, in order: parse the header — rcode NOERROR with at least one answer record is positive;
rcode NXDOMAIN, or NOERROR with zero answers, is negative; every other rcode returns null.
Positive: `ttl = min(packet.decrementTtls(copy, 0) result, max_ttl)` — since `decrementTtls`
mutates, `classify` runs it on a stack copy (`[65535]u8` is too big for the stack; instead compute
the min TTL by iterating records via the parser without mutation — `record` module iteration, skip
OPT). A parse error returns null. Negative: min(SOA record TTL, SOA MINIMUM) from the authority section per RFC 2308 §5, capped by
`negative_ttl_max`; no SOA → null; `negative_ttl_max == 0` → null (ruling 6). A TTL of 0 → null.
A TTL with the top bit set is read as zero per RFC 2181 §8 (packet.zig:220 leaves that to the
caller), on both paths — such a response is not cached.
### S1.3 Storage and API
```zig
pub const Config = struct { size: u32, negative_ttl_max: u32 };
pub const Stats = struct { hits, misses, inserts, evictions, expirations, invalid_hits: u64 = 0 };
pub const DnsCache = struct {
pub fn init(gpa: Allocator, config: Config) Allocator.Error!DnsCache; // allocates slots + index once
pub fn deinit(self: *DnsCache) void;
/// Stores a clone of `response`. A key already present is replaced in place.
pub fn put(self: *, now_s: i64, key: []const u8, response: []const u8, class: Class) Allocator.Error!void;
/// Copies the entry into `out`, rewrites nothing but the TTLs (caller does setId),
/// returns null on miss/expiry. `out.len >= response len` is the caller's problem;
/// a too-small `out` is a miss counted under `misses`.
pub fn get(self: *, now_s: i64, key: []const u8, out: []u8) ?[]u8;
/// Removes expired entries; returns how many. Phase 7 schedules it.
pub fn sweep(self: *, now_s: i64) u32;
pub fn len(self: *const) u32;
pub fn memoryBytes(self: *const) usize;
stats: Stats,
};
```
Slot: inline key `[max_key_len]u8` + `key_len` (no allocation), `bytes: []u8` (gpa-owned clone),
`stored_at_s: i64`, `expires_at_s: i64`, `negative: bool`, `referenced: bool`, `occupied: bool`.
Index: `HashMapUnmanaged` keyed by the slot's key slice (context hashes the slice) to `u32` slot
index, `ensureTotalCapacity(config.size)` at init — no rehash ever. CLOCK: on `get` hit set
`referenced`; when `put` finds no free slot, advance the hand clearing `referenced` until an
unreferenced slot, evict it (`evictions += 1`). Expired-on-access is freed and counted under
`expirations`, then treated as a miss/free slot.
`get` computes `elapsed = now_s - stored_at_s`, copies, then `packet.decrementTtls(copy,
elapsed)`. An error from it removes the entry, counts `invalid_hits`, returns null — a poisoned
entry never serves twice.
### S1.4 Tests (in-file, ≥ 14)
buildKey lowercases and strips the dot; distinct qtype/qclass/DO/ECS produce distinct keys; ECS
absent vs present differ; classify: positive min-TTL over multiple answers; ceiling applied; NXDOMAIN
uses SOA minimum; capped by negative_ttl_max; `negative_ttl_max = 0` returns null; SERVFAIL null;
zero TTL null; put/get round-trip with TTL decrement asserted via re-parse; expiry is a miss and
counted; CLOCK evicts the unreferenced entry, not the referenced one; replace-in-place does not
grow `len`; sweep removes only expired; `checkAllAllocationFailures` on init+put.
### S1.5 Acceptance
- [ ] `zig fmt --check` + `zig ast-check` clean; in-file tests pass standalone via a src/-level
temp root.
- [ ] `grep -n "std.Io" src/cache/dns_cache.zig` hits doc comments at most.
- [ ] `get` performs zero allocations (no allocator parameter exists on it).
---
## Session S2: rate limiter — `src/server/rate_limiter.zig`
Imports: `std`, `../platform/address.zig`. No `std.Io`.
```zig
pub const Config = struct { limit: u32, window_seconds: u32 };
pub const Stats = struct { allowed, refused, untracked: u64 = 0 };
pub const RateLimiter = struct {
pub fn init(gpa: Allocator, config: Config) Allocator.Error!RateLimiter; // capacity fixed at init
pub fn deinit(self: *) void;
/// True = process the query; false = answer REFUSED. Never errors, never allocates.
pub fn check(self: *, now: std.Io.Timestamp, key: address.NetAddress.Key) bool;
/// Drops entries whose window ended before the previous full window. Phase 7 schedules it.
pub fn sweep(self: *, now: std.Io.Timestamp) u32;
stats: Stats,
};
pub const max_clients = 4096;
```
Fixed window per key: entry `{window_start_ns: i96, count: u32}` in a `HashMapUnmanaged` with
capacity `max_clients` ensured at init. `check`: if the entry's window has ended, reset it to the
current window. `count >= limit` → refused. Map full and key unknown: allow, count `untracked`
refusing unseen clients because the table is full would let 4096 attackers deny every new device,
and a household LAN never has 4096 honest clients; the counter makes the state visible. `sweep`
walks and removes stale entries (collect keys first — map modification invalidates iterators).
`std.Io.Timestamp` is a plain value type (`{nanoseconds: i96}`) — using it keeps this file pure;
callers pass `.awake` timestamps.
Tests (≥ 8): allows up to limit, refuses limit+1; new window resets; v4 and v6 keys independent;
v4-mapped v6 shares the v4 bucket (via `address.fromIp` in the test); sweep removes only stale;
map-full behavior allows and counts; stats add up; window arithmetic at i96 scale (a timestamp far
from zero).
Acceptance: fmt/ast-check clean; no allocation in `check` (signature proves it); in-file tests
pass standalone.
---
## Session S3: queries repo — `src/storage/repositories/queries_repo.zig`
Imports: `std`, `../db.zig`. Pure DB code, no `std.Io`. Follows the milestone-4 repository idiom
(free functions over `*db.Db` — read `groups_repo.zig` first), plus one long-lived-statement
struct the flush loop owns (`db.zig:360` names this file as the reason no statement cache exists).
```zig
pub const Row = struct {
timestamp: i64,
domain: []const u8, // already privacy-transformed by the logger
client_ip: []const u8, // ditto
qtype: ?u16,
blocked: bool,
block_reason: ?[]const u8,
response_time_us: ?i64,
cache_hit: ?bool,
upstream: ?[]const u8,
};
/// Owns the prepared statements of the flush loop. Init once, reuse per batch.
pub const BatchWriter = struct {
pub fn init(database: *db.Db) db.Error!BatchWriter;
pub fn deinit(self: *) void;
/// One transaction. Domains are interned via INSERT OR IGNORE + SELECT id.
pub fn writeBatch(self: *, rows: []const Row) db.Error!void;
};
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64; // rows deleted
pub fn checkpointTruncate(database: *db.Db) db.Error!void; // PRAGMA wal_checkpoint(TRUNCATE)
pub fn vacuum(database: *db.Db) db.Error!void;
pub fn countRows(database: *db.Db) db.Error!i64;
pub fn countDomains(database: *db.Db) db.Error!i64;
```
`writeBatch` on an empty slice returns without opening a transaction. `pruneOlderThan` deletes
`query_log` rows only — orphaned `domains` rows stay (they are a dimension table; re-interning is
cheaper than referential garbage collection, and §11.3 sets no requirement). Statements: three in
`BatchWriter` (insert-or-ignore domain, select domain id, insert row), reset+rebind per use.
Tests (≥ 8, all against `:memory:` + `querylog_schema.ddl`): batch inserts rows and interns
domains once; second batch reuses the domain id; nullable columns round-trip null and value;
empty batch writes nothing; prune deletes strictly-older rows only; prune returns the count;
checkpoint and vacuum execute without error on a file DB (use `:memory:` where legal, a tmp file
where WAL is needed — vacuum works on both); countRows/countDomains agree with inserts.
Acceptance: fmt/ast-check clean; tests pass standalone via src/-level temp root with `-lc
-lsqlite3`.
---
## Session S4: async query logger — `src/storage/logger.zig` (needs S3)
Imports: `std`, `db.zig`, `repositories/queries_repo.zig`, `../config/model.zig`,
`disk_monitor.zig` (S5's file — wave 2 starts after wave 1 verifies, so it exists).
### S4.1 Entry — self-contained (Queue copies raw bytes)
```zig
pub const Entry = struct {
timestamp: i64,
domain_buf: [253]u8, domain_len: u8,
client_buf: [45]u8, client_len: u8, // RFC 5952 text, fits any v6
qtype: ?u16,
blocked: bool,
reason_buf: [32]u8, reason_len: u8, // "" = null in the DB
response_time_us: ?i64,
cache_hit: ?bool,
upstream_buf: [64]u8, upstream_len: u8, // "" = null
pub fn domain(self: *const) []const u8; // + client/reason/upstream accessors
};
```
### S4.2 Logger
```zig
pub const flush_batch = 100;
pub const flush_interval_ms = 100;
pub const hidden_marker = "hidden";
pub const Logger = struct {
/// `queue_buf.len` is the backpressure cap: pass cfg.query_log_buffer_max entries.
pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger;
/// Applies the privacy transforms, then enqueues without blocking. A full queue drops
/// the OLDEST unflushed entry (PLAN §11.4) and increments `queries_dropped`.
pub fn log(self: *, io: std.Io, entry: Entry) void;
/// The writer task. Owns `database` for its whole life. Runs until `shutdown`.
pub fn runWriter(self: *, io: std.Io, database: *db.Db, monitor: ?*disk_monitor.Monitor)
std.Io.Cancelable!void;
/// Closes the queue; runWriter drains what remains, flushes, and returns.
pub fn shutdown(self: *, io: std.Io) void;
queries_dropped: std.atomic.Value(u64),
rows_written: std.atomic.Value(u64),
batches_gated: std.atomic.Value(u64),
};
```
- Privacy (§11.4): `hide_domains` replaces the domain with `hidden_marker`; `hide_client_ips`
likewise. Applied inside `log`, so the queue never holds the real value — the transform runs
before persistence and before any future SSE fanout by construction (ruling 4).
- Drop-oldest: `put(io, &.{entry}, 0)`; on 0, `get` one entry nonblocking (min 0), count it
dropped, retry the put — until the put succeeds, one drop counted per failed attempt. A fixed
attempt cap is wrong under contention (one call would pay for two old entries plus its own).
The only early exit is a zero-capacity queue, which has nothing to drop.
- `runWriter` loop: `getOne` (blocking; `error.Closed` → final drain + flush + return). Then
accumulate up to `flush_batch` entries: nonblocking `get` first; while short of the batch and
the interval clock (started at the first entry, `.awake`) has time left, race `getOne` against
the remaining interval via `Select` (manager's `fetchWithin` shape). Flush: if
`monitor != null and !monitor.?.writesAllowed()`, do not write — hold the batch, sleep 1 s
(`.awake`), re-check; count each held cycle under `batches_gated`. §11.6: log flushes stop at
critical, the queue keeps dropping oldest behind them. On `writeBatch` error: log at `warn`,
drop the batch (it is expendable log data; blocking would fill the queue), continue.
- Entries convert to `queries_repo.Row` at flush (slices point into the batch array — safe, the
batch lives across the call).
Tests (≥ 8): transforms applied at enqueue (inspect the queue copy); drop-oldest drops the oldest
and counts; entry accessors round-trip; batch conversion maps "" to null; a `runWriter`
smoke over `:memory:` — enqueue N, shutdown, all N in the DB (single-threaded: run the writer via
`io.concurrent` in the test with a Threaded instance — this is the one in-file test that touches
`std.Io`; keep it gated under a plain `test` since it needs no network); gating holds rows back
and releases them (fake monitor state flip); writeBatch failure drops and continues (close the DB
under the writer? if unreachable in-file, mark for S8).
Acceptance: fmt/ast-check clean; `grep -c "std.log.err" src/storage/logger.zig` = 0.
---
## Session S5: statfs + disk monitor — `src/platform/statfs.zig`, `src/storage/disk_monitor.zig`
### S5.1 `src/platform/statfs.zig`
```zig
pub const StatVfs = extern struct {
f_bsize: c_ulong, f_frsize: c_ulong,
f_blocks: u64, f_bfree: u64, f_bavail: u64,
f_files: u64, f_ffree: u64, f_favail: u64,
f_fsid: c_ulong, f_flag: c_ulong, f_namemax: c_ulong,
__reserved: [6]c_int,
};
extern fn statvfs(path: [*:0]const u8, buf: *StatVfs) c_int;
pub fn freeBytes(path: [:0]const u8) error{StatFailed}!u64; // f_bavail * f_frsize
```
The 64-bit glibc and musl `struct statvfs` layouts agree field-for-field; both build targets are
64-bit. One test: `freeBytes(".")` returns a nonzero value (gated `-Dintegration`? no — it is
hermetic and deterministic-enough: assert `> 0`; a full disk failing CI is a real signal).
### S5.2 `src/storage/disk_monitor.zig`
```zig
pub const State = enum(u8) { ok, warn, critical };
pub const Gauges = struct { free_bytes, db_bytes, log_bytes: u64 };
pub fn classify(free_bytes: u64, cfg: model.Disk) State; // pure; < min → critical, < warn → warn
pub const Monitor = struct {
pub fn init(cfg: model.Disk, data_dir: std.Io.Dir, data_path: [:0]const u8,
log_dir_path: ?[:0]const u8) Monitor;
/// One sample: statvfs on data_path, sizes of *.db/-wal/-shm under data_dir and of the
/// log dir. Stores state + gauges atomically. Failures count `sample_failures`, keep the
/// previous state, and log at warn (deduped by the sink).
pub fn sample(self: *, io: std.Io) void;
pub fn state(self: *const) State; // atomic load
pub fn writesAllowed(self: *const) bool; // state != .critical
pub fn gauges(self: *const) Gauges; // atomics, individually consistent
/// Loop: sample, sleep 60 s (.boot), repeat. Phase 7 starts it.
pub fn run(self: *, io: std.Io) std.Io.Cancelable!void;
sample_failures: std.atomic.Value(u64),
};
pub const sample_interval_s = 60;
```
State is `std.atomic.Value(u8)`; a reader never sees a torn value. On the ok↔warn↔critical edges
log one line at `warn` (state changes only, not every sample). The blocklist-update gate (§11.6
"stop non-essential writes") is consumed by the logger now and by the manager in Phase 7 — no
milestone-5 file is edited here.
Tests: `classify` boundary cases (exactly min, exactly warn, between, above); state-change
logging is observable via state transitions (assert states, not logs); init leaves `.ok` with
zero gauges; gauge arithmetic (a fixture dir with files of known sizes, using `std.Io.Dir` in a
plain test with a Threaded instance).
Acceptance: fmt/ast-check clean on both files; `classify` covered exhaustively.
---
## Session S6: log sink — `src/platform/logging.zig`
The custom `std.log` sink: level filter, stderr or rotating file output, upstream-error dedup
(§11.6). Imports: `std`, `../config/model.zig`.
```zig
/// Matches std.Options.logFn. Before install(), passes through to stderr formatting.
pub fn logFn(comptime level: std.log.Level, comptime scope: @Type(.enum_literal),
comptime format: []const u8, args: anytype) void;
/// Called once from main() after config parse. Not called by tests.
pub fn install(io: std.Io, cfg: model.Logging) void;
pub fn deinstall() void; // flushes and closes the file; for tests and shutdown
pub const Stats = struct { lines_written, lines_deduped, rotations, sink_errors: u64 };
pub fn stats() Stats;
```
Verify the exact `std.Options.logFn` signature against lib/std/std.zig + log.zig before writing —
do not trust this spec's sketch.
- Global sink state behind a `std.Thread.Mutex`? **No**`std.Io.Mutex` needs an io; `logFn`
receives none. Store the installed `std.Io` by value in the global state (it is a plain
interface value); guard the whole sink with `std.debug` -style spinlock? Verify what std.log's
default lock does in 0.16 and mirror it. If std provides no lock for custom logFn, use a
file-scope `std.Thread.Mutex` (std.Thread still exists for this — verify) or an atomic spin
guard; state the choice in a comment. This is the one deliberately-open point in this spec:
resolve it against the stdlib source and record the resolution for "As built".
- Level: drop below `cfg.level` (mapping model's level enum to std.log.Level — write it).
- Dedup: for `warn`+ lines whose scope is one of `.doh_client`, `.dot_client`, `.pool`,
`.forward_client`: key = scope + formatted message truncated to 96 bytes; a key seen within the
last 60 s (`.awake`) is dropped and counted. Fixed 64-entry table, oldest-stamp replacement.
- File mode: append (seekTo end-of-file pattern), byte counter from `file.length` at open; a line
that would cross `cfg.maxLogBytes()` triggers rotation first: delete `.{max_files-1}`, shift
`.N``.N+1`, rename live → `.1`, reopen fresh. All under the sink lock. Rotation or write
failure: fall back to stderr for that line, count `sink_errors`, keep trying the file next line.
- stderr mode: format + single `write` per line (std.debug.lockStdErr equivalent — verify).
Tests: the pure pieces — dedup table admits first, drops repeat inside 60 s, admits after; level
mapping; rotation name arithmetic (`rotatedName(buf, path, n)`). File behavior lands in S8 (needs
a real dir). `logFn` itself is NOT exercised in tests (installing a sink under the test runner
would eat the harness's own logs).
Acceptance: fmt/ast-check clean; no `std.log` call inside the sink itself (it would recurse);
dedup + rotation arithmetic covered.
---
## Session S7: retention — `src/storage/retention.zig` (needs S3)
Imports: `std`, `db.zig`, `repositories/queries_repo.zig`, `../config/model.zig`.
```zig
pub const vacuum_every_passes = 7;
pub const Stats = struct { passes, rows_pruned, checkpoints, vacuums: u64 = 0 };
pub const Retention = struct {
pub fn init(cfg: model.Logging) Retention;
/// One pass: prune rows older than now - retentionSeconds, checkpoint, and on every 7th
/// pass vacuum. DB errors log at warn and the pass counts as done (next pass retries).
pub fn runOnce(self: *, io: std.Io, database: *db.Db) void;
/// Daily loop (.boot), first pass immediately. `database` must be a dedicated
/// connection no other task uses while the loop runs; Phase 7 opens it.
pub fn run(self: *, io: std.Io, database: *db.Db) std.Io.Cancelable!void;
stats: Stats,
};
pub const pass_interval_s = 86_400;
```
`runOnce` computes the cutoff from `Clock.real.now(io).toSeconds() - cfg.retentionSeconds()`.
Sharing one handle between concurrent tasks is NOT safe: FULLMUTEX serializes single SQLite calls,
but a transaction is connection state — a prune landing between another task's BEGIN and COMMIT
joins that transaction. The contract therefore requires a dedicated connection; cross-connection
isolation is SQLite's own (WAL + busy_timeout), and a pass losing a race sees Busy/Locked, logs at
warn, and retries next interval.
Tests (`:memory:` + ddl): a pass prunes exactly the old rows; the 7th pass vacuums (assert via
`stats.vacuums` after 7 `runOnce` calls); a pass on an empty DB is a no-op that still counts;
cutoff arithmetic honors `retention_days`.
Acceptance: fmt/ast-check clean; tests standalone with `-lc -lsqlite3`.
---
## Session S8: integration — `src/storage/phase6_integration_test.zig` (needs all)
Gated by `build_options.integration` exactly like `storage_integration_test.zig` (same Fixture
idiom over `.zig-cache/tmp/`). Cases, named `"S8 case N: …"`:
1. logger end-to-end: real file `querylog.db` via `querylog_schema.open`, writer task under
`io.concurrent`, 250 entries → shutdown → 250 rows, domains interned (fewer domain rows than
query rows), fingerprint stamped.
2. flush on interval: one entry, no shutdown; poll until it lands; well under 10× the interval.
3. backpressure: queue capacity 8, enqueue 20 with the writer stalled (gated by a monitor stub at
critical); `queries_dropped >= 12`, the survivors are the NEWEST entries, then un-gate and
confirm the survivors landed.
4. privacy: hide both → every row's domain and client_ip read `hidden`.
5. retention: back-dated rows pruned, fresh rows kept, WAL truncated after checkpoint (assert
`-wal` size 0 or absent).
6. disk thresholds end-to-end (§17 exit criterion): monitor with `min_free_mb` far above the real
free space → `.critical`, `writesAllowed() == false`, logger gates (case-3 machinery),
`batches_gated > 0`; then thresholds far below → `.ok` and flushing resumes.
7. log sink file mode: install to a fixture path with `max_size_mb` tiny (write the size gate via
a test-only override — if `install` takes cfg, a 1 MB minimum makes this slow; add
`pub fn installForTest(io, cfg, max_bytes_override)` if needed and mark it test-only),
emit lines past the limit → `.1` exists, live file small, `max_files` honored, then
`deinstall`. If overriding proves ugly, rotation is proven at the arithmetic level in S6 and
this case shrinks to append+reopen round-trip — say which in the report.
8. cache with real packets: build a response via `ResponseBuilder`, `classify` + `put`, advance
`now_s`, `get` → TTLs visibly decremented (re-parse), expiry at the boundary is a miss.
9. rate limiter + address integration: 1001 checks from one v6-mapped-v4 client inside one window
→ exactly one refused… (limit 1000); a second distinct client unaffected.
Acceptance: `zig build test -Dintegration` exit 0 with all 9 passing; no case sleeps longer than
2 s of wall time; no network.
---
## Module Layout (new files)
| File | Purpose |
|---|---|
| `src/cache/dns_cache.zig` | pure TTL cache: key, classify, CLOCK-bounded store |
| `src/server/rate_limiter.zig` | pure fixed-window per-client limiter |
| `src/storage/repositories/queries_repo.zig` | query_log/domains writes, prune, checkpoint, vacuum |
| `src/storage/logger.zig` | Io.Queue async logger, privacy, backpressure, disk gate |
| `src/platform/statfs.zig` | libc statvfs wrapper |
| `src/storage/disk_monitor.zig` | free-space thresholds, gauges, 60 s sampler |
| `src/platform/logging.zig` | std.log sink: level, rotation, upstream dedup |
| `src/storage/retention.zig` | daily prune + checkpoint + weekly vacuum |
| `src/storage/phase6_integration_test.zig` | S8 cases |
## File Ownership
| Files | Owner |
|---|---|
| `src/cache/dns_cache.zig` | S1 |
| `src/server/rate_limiter.zig` | S2 |
| `src/storage/repositories/queries_repo.zig` | S3 (frozen after S3 verifies) |
| `src/storage/logger.zig` | S4 |
| `src/platform/statfs.zig`, `src/storage/disk_monitor.zig` | S5 (frozen after wave 1) |
| `src/platform/logging.zig` | S6 |
| `src/storage/retention.zig` | S7 |
| `src/storage/phase6_integration_test.zig` | S8 |
| `src/tests.zig`, `build.zig`, `src/main.zig` (std_options) | orchestrator |
No session touches `src/dns/`, `src/upstream/`, `src/filter/`, `src/local/`, `src/server/handler.zig`,
`src/server/udp_server.zig`, `src/server/tcp_server.zig`, `src/storage/db.zig`, any milestone 45
repository, `src/config/`, or `src/cli.zig`. A needed change there is reported, not made.
## Acceptance Criteria (Milestone 6 Complete)
- [ ] `zig build test` exit 0 with all nine new files wired into `src/tests.zig`.
- [ ] `zig build test -Dintegration` exit 0 including the 9 S8 cases and every prior milestone's.
- [ ] `zig build cross` still produces two statically linked executables (statvfs links against
musl).
- [ ] `grep -rn "std.log.err" src/cache/ src/storage/logger.zig src/storage/disk_monitor.zig
src/storage/retention.zig src/server/rate_limiter.zig src/platform/statfs.zig
src/platform/logging.zig` → nothing.
- [ ] `validate.zig` rejects `rate_window_seconds = 0` (rule exists at validate.zig:221; covered
by milestone-4 tests).
- [ ] `DnsCache.get`, `RateLimiter.check` have no allocator in reach — signatures prove the
no-allocation hit path.
- [ ] The disk-degradation integration case (S8 case 6) passes — PLAN §17's Phase 6 exit
criterion.
- [ ] `zig fmt --check` clean repo-wide; GPG-signed lowercase commit.
## Anti-Requirements
- **No handler or server wiring.** `handler.zig` keeps its signature; nothing calls the cache,
the limiter, or the logger from the query path. Phase 7.
- **No client-row pruning and no auto-materialization.** Phase 7 (ruling 1).
- **No web/API/SSE/metrics/health payloads.** Phase 8 reads the accessors this milestone exposes.
- **No API token bucket.** §10's API limiter is Phase 8.
- **No cache persistence, no cache serialization.** In-memory only (§8).
- **No ECS parsing.** The cache key accepts ECS bytes the caller extracted; extracting them from
OPT is the handler's job in Phase 7.
- **No statement cache in db.zig.** `BatchWriter` owns its statements; `db.zig` stays as is.
- **No new config keys and no schema change.** Every knob this milestone reads exists in
`model.zig`; flush tuning is comptime (ruling 3).
- **No log sink installation under the test runner.** `install` runs only from `main`.
- **No epoll/timerfd/signalfd.** Loops sleep on `Clock.Duration`; Phase 7 owns lifecycle.
## As built (S1S7 and orchestrator wiring)
Deviations from the text above, recorded after the sessions verified. Where this section and the
session text disagree, this section wins.
**S1 dns_cache.** `Config` is an alias of `model.Cache`, not a duplicate struct. `classify`
computes the positive min-TTL by iterating records (no mutation, no 64 KiB copy); NODATA (NOERROR,
zero answers) takes the negative path with NXDOMAIN per RFC 2308 §2; a malformed SOA means "do not
cache", not an error; `negative_ttl_max == 0` disables negatives only — positives still cache. A
`put` on an existing key keeps the slot's CLOCK reference bit. A slot the CLOCK hand reclaims
because it expired counts `expirations`, not `evictions`. `size == 0` turns caching off (`put`
no-ops). A backwards clock ages by zero. `misses` covers lookup failure, expiry and a too-small
`out`; a poisoned entry counts `invalid_hits` only. The slot's `negative` flag is stored but has
no accessor yet — Phase 8 adds one if it wants the split. After review: the negative TTL is
min(SOA record TTL, SOA MINIMUM) per RFC 2308 §5, and a top-bit-set TTL reads as zero per RFC 2181
§8 on every non-OPT record in any section (positive path) and both SOA fields (negative path) — a
zero result means the response is not cached. When `put` stores a negative response it lowers the
clone's first authority SOA record TTL to the entry's lifetime, so a hit at elapsed 0 serves an
SOA TTL equal to the entry TTL and downstream caches do not hold the negative answer past it
(RFC 2308 §3); the caller's buffer is not modified. On the positive path `put` caps every non-OPT
record TTL in the clone at `max_ttl_seconds` for the same reason — the stored bytes never
advertise a lifetime past the entry's ceiling (the cap is our ruling 5, not an RFC rule; unbound's
cache-max-ttl serves the capped value the same way). A negative response's non-SOA records are
deliberately not capped — the clamped SOA is the record that governs negative caching downstream.
The poisoned-packet ageing test's fixture keeps its TTL under `max_ttl_seconds` on purpose, so
`put` stores it unmodified and only the ageing inside `get` breaks it.
**S2 rate_limiter.** Windows anchor at each client's first query (`.awake` origin is arbitrary).
`sweep` drops entries older than two full windows (exactly two survives). `untracked` is a subset
of `allowed`, so `allowed + refused` equals the number of `check` calls. `init` asserts
`window_seconds != 0` (validate.zig:221 guards real configs). `limit == 0` refuses everything.
Added: `trackedClients()` occupancy accessor. `sweep` collects stale keys into a buffer allocated
at init (~68 KiB), not onto the stack.
**S3 queries_repo.** Domain interning is INSERT OR IGNORE + SELECT (no `lastInsertRowid` — unset
on ignore); a missing row right after the insert is `error.NotFound`. `select_domain` resets
immediately after the id is read; `errdefer tx.rollback()` is declared before `errdefer
resetAll()` so no cursor is open at ROLLBACK. `checkpointTruncate` treats a blocked checkpoint as
success (SQLite reports it in a discarded row; retention retries next pass).
**S4 logger.** `Entry.init(Entry.Fields)` builds an entry from borrowed slices, copying and
truncating; `setDomain`/`setClientIp` are public for the privacy transform. **Spec correction:**
the `fetchWithin` shape's `cancelDiscard` is wrong here — a `getOne` that loses the race has
already removed an entry, and discarding its result loses the entry silently. `getWithin` drains
the Select with a `while (race.cancel())` loop; an entry recovered on the cancellation path counts
`queries_dropped`. A batch dropped by a `writeBatch` failure adds its length to `queries_dropped`.
The interval deadline starts at the first entry of a batch. `gate_retry_s = 1` is public. Queue
facts verified: `put` returns `Closed` even with space; `get(min=0)` on a closed non-empty queue
returns elements and reports `Closed` only when empty — that is what makes the shutdown drain
correct. A writer sitting in the disk gate at shutdown holds its batch until un-gated or
canceled (documented on `shutdown`); `Logger` must not move after `init`. After review: `fill`
reports its count through an out-parameter, so a canceled `fill` or `flush` counts every entry
still held in the batch under `queries_dropped` (including the gate-blocked cancellation); a
`BatchWriter.init` failure warns, sets `writer_failed: std.atomic.Value(bool)`, closes the queue
and drains it with `Queue.getUncancelable` — uncancelable specifically so a cancellation racing
the failure cannot leave buffered entries uncounted (no other consumer can reach them at that
point); the writer never pretends to run. The zero-capacity early exit reads
`queue.capacity()`. The prepare-failure path and
the cancellation accounting are both covered in-file (schema-less `:memory:` DB; deterministic
2-entry batch canceled in the gate).
**S5 statfs + disk_monitor.** The statvfs layout is pinned by `@offsetOf`/`@sizeOf` tests (112
bytes); musl verified from the zig-shipped headers, glibc from the system's. `init` requires
`data_dir` opened with `.iterate = true` (documented). `log_dir_path` opens via `cwd()` per
sample; null keeps `log_bytes` at 0. A failed statvfs leaves the state untouched; a failed size
scan keeps that gauge's prior value while the state still publishes. A per-file `statFile` failure
inside a size scan fails that whole scan — the gauge keeps its prior value and `sample_failures`
increments once for the scan, not per file; `error.FileNotFound` is the one exception (a file
deleted between `iterate` and `statFile` is normal on rotation or recreate — skipped, scan
continues). `classify` checks min before
warn, so warn-below-min misconfiguration reports the severer state. `run` samples first, then
sleeps. Sizes come from `Dir.statFile`.
**S6 logging sink.** **Spec corrections:** the `logFn` scope parameter is `comptime scope:
@EnumLiteral()`, and `std.Thread.Mutex` does not exist in 0.16.0. The sink lock is
`std.debug.lockStderr`/`unlockStderr` (the same lock `std.log.defaultLog` uses; documented
recursive; needs no io). Added: `installForTest(io, cfg, max_bytes_override)`; public pure
helpers `toStdLevel`, `enabled`, `isDedupScope`, `buildKey`, `DedupTable`, `rotatedName`.
`max_files` counts the live file (generations `.1`…`.{max_files-1}`); `max_files < 2` truncates
in place. `LogOutput.syslog` routes to stderr (journald captures it — the only supported
deployment). File lines are `<unix_seconds> <level>(<scope>): <msg>\n`, per-line durable (fresh
writer, seekTo, writeAll, flush); write failure closes the file and the next line reopens it;
overlong paths fall back to stderr and count `sink_errors`; cancel protection wraps file work.
After review: `<msg>` is escaped on BOTH output paths — `\\`, newline, carriage return and tab as
two-character escapes, every other byte below 0x20 plus DEL as `\xNN` (a deliberate divergence
from `std.log.defaultLog`, which writes raw; these lines are parsed by operators and journald);
`max_escaped_message_bytes = max_message_bytes * 4` is public and the line buffer holds the worst
case. A failed rotation step counts `sink_errors`, sends the line to stderr, and leaves the live
file CLOSED until a later line completes the rotation — the oversized file is never reopened for
append (`rotate_pending`); `rotations` counts completed rotations only. One exception: a single
line longer than `max_bytes` is written to an empty file rather than rotated forever.
`lines_written` counts only lines a sink accepted; each failed write attempt counts one
`sink_errors` (file-fail + stderr-success = one of each). The scope name is truncated to
`max_scope_name_bytes` (32) by `boundedScopeName`, applied once in `logFn` so the file and stderr
paths render the same event identically; the public constant `max_line_header_bytes` (65) bounds
the record header and the file line buffer is `max_escaped_message_bytes +
max_line_header_bytes`. Record construction is `buildLine`, a pure function taking the unix
seconds as a parameter — it returns an error instead of a partial record, and a failure counts
one `sink_error` and sends the line to stderr, so a malformed record never reaches the file.
Counting contract: every path where `prepareFileLocked` returns false has already counted exactly
one `sink_error` at the failing step (the caller never counts again); `rotateLocked` counts its
own failure.
**S7 retention.** Prune, checkpoint and vacuum are independent within a pass — a failed prune
does not skip the checkpoint (the WAL was filled by the logger, not this pass). `passes`
increments at the top of `runOnce`; vacuum fires at `passes % 7 == 0`. The cutoff is
strictly-less-than (a row exactly at the cutoff survives). `runOnce` opens no transaction; the
contract on `run` requires a dedicated connection (concurrent sharing joins the other task's
transaction — FULLMUTEX does not prevent that); Phase 7 opens it. Sequential use of one handle,
as S8 case 5 does, is permitted.
**Orchestrator wiring.** All eight new files (S8's included) imported individually by
`src/tests.zig`. `src/main.zig` gained `pub const std_options: std.Options = .{ .logFn =
logging.logFn };` — effective in the executable build, inert under the test runner (tests.zig is
that root); `zig build` type-checks the sink through real call sites. The spec's claim that a
`rate_window_seconds` rule was missing from validate.zig was wrong — the rule exists at
validate.zig:221; nothing was added.
**S8 integration.** Case 7 runs the full rotation via `installForTest(io, cfg, 256)` with
`max_files = 3`, calling `logging.logFn` directly (the test runner owns `std_options`, so a
`std.log` call would never reach the sink) and comparing counters as before/after deltas
(`install`/`deinstall` do not reset stats); it also proves append-and-reopen byte-identity. Case 3
enqueues the full burst before the writer starts, making "12 dropped, newest 8 survive" exact, and
starts the writer gated. Cases 3 and 6 un-gate before `shutdown` (the gate-hold documented in S4).
Case 6 samples real `statvfs` against the fixture dir and flips state by swapping `monitor.cfg`
between huge and zero thresholds. Case 5 asserts the WAL was non-empty before the checkpoint, so
truncation is proven, not assumed. Shared-Db reads happen only while the writer sleeps in the gate
or after its future is awaited. All cases use `testing.io`. Per-case wall time stays under 1.5 s;
cases 3 and 6 pay `gate_retry_s`. With `integration = false`, all 9 skip.