44 KiB
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.zigandsrc/server/rate_limiter.zigtake timestamps as parameters and hold nostd.Io, no clock, no socket.std.Ioappears only insrc/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.getandRateLimiter.checkallocate nothing.DnsCache.putallocates 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.errin any file this milestone writes. A condition returned as a typed error logs atwarnat most.errstays reserved for swallowed failures, and this milestone swallows none. - Timestamps follow the house style (health.zig):
std.Io.Timestampparameters, arithmetic on.nanoseconds, wall seconds for DB rows viaClock.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 atwarnand the loop continues, cancellation returns. Phase 7 starts them.
Resolved PLAN ambiguities (rulings for this milestone)
- Client-row pruning is Phase 7. §7.2's "retention drops
hand_edited=0clients" deletes fromconfig.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 touchesquerylog.dbonly. - Free space drives the thresholds; sizes are gauges. The monitor samples
statvfsfree 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. - Flush tuning is comptime.
flush_batch = 100,flush_interval_ms = 100as public constants inlogger.zig. §12.1 defines no config keys for them and a household deployment never tunes them. - 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. - The positive-TTL sanity ceiling is 86 400 seconds, the same
max_ttl_secondsconstantvalidate.zig:196already enforces onblocking.ttlandcache.negative_ttl_max. Comptime constant indns_cache.zigwith a comment naming that origin. No new config key. negative_ttl_max = 0disables negative caching (the response is not stored), not the cap.validate.zigalready caps the field atmax_ttl_seconds, so "no cap" would be expressible as 86 400 anyway; "0 disables" only has meaning as an off switch.cache.sizecounts 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.- 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.
- 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.zigdeduplicates warn-and-above lines from the upstream scopes at one per key per minute. No milestone-3 file is edited. - 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);getOneblocks;close(io)unblocks it witherror.Closed. Elements are copied raw (Io.zig:2189) — queue elements must be self-contained values, no slices into caller memory.std.Io.Conditionhas notimedWait. The timed primitive isEvent.waitTimeout(Io.zig:1827). The logger's flush interval usesSelectracinggetOneagainst aClock.Durationsleep, thefetchWithinpattern from manager.zig:722.- No statvfs in std.
src/platform/statfs.zigdeclaresextern fn statvfsagainst 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.deleteFilerotate. 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.HashMapUnmanagedfrom key to slot index withensureTotalCapacityat 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. Withelapsed = 0it is a pure validate-and-min-TTL pass.packet.setId(packet.zig:202) rewrites the transaction ID.db.zig:Stmt.resetclears bindings — rebind everything each iteration.Db/Stmtare not thread-safe; the logger's writer task owns itsDbhandle exclusively. A batch is oneTx.begin+ reset/rebind/step loop +commit.address.NetAddress.Keyis[17]u8covering both families;fromIpfolds 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
/// 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
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
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-checkclean; in-file tests pass standalone via a src/-level temp root.grep -n "std.Io" src/cache/dns_cache.zighits doc comments at most.getperforms 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.
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).
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)
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
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_domainsreplaces the domain withhidden_marker;hide_client_ipslikewise. Applied insidelog, 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,getone 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. runWriterloop:getOne(blocking;error.Closed→ final drain + flush + return). Then accumulate up toflush_batchentries: nonblockinggetfirst; while short of the batch and the interval clock (started at the first entry,.awake) has time left, racegetOneagainst the remaining interval viaSelect(manager'sfetchWithinshape). Flush: ifmonitor != null and !monitor.?.writesAllowed(), do not write — hold the batch, sleep 1 s (.awake), re-check; count each held cycle underbatches_gated. §11.6: log flushes stop at critical, the queue keeps dropping oldest behind them. OnwriteBatcherror: log atwarn, drop the batch (it is expendable log data; blocking would fill the queue), continue.- Entries convert to
queries_repo.Rowat 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
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
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.
/// 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.Mutexneeds an io;logFnreceives none. Store the installedstd.Ioby value in the global state (it is a plain interface value); guard the whole sink withstd.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-scopestd.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.lengthat open; a line that would crosscfg.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, countsink_errors, keep trying the file next line. - stderr mode: format + single
writeper 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.
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: …":
- logger end-to-end: real file
querylog.dbviaquerylog_schema.open, writer task underio.concurrent, 250 entries → shutdown → 250 rows, domains interned (fewer domain rows than query rows), fingerprint stamped. - flush on interval: one entry, no shutdown; poll until it lands; well under 10× the interval.
- 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. - privacy: hide both → every row's domain and client_ip read
hidden. - retention: back-dated rows pruned, fresh rows kept, WAL truncated after checkpoint (assert
-walsize 0 or absent). - disk thresholds end-to-end (§17 exit criterion): monitor with
min_free_mbfar above the real free space →.critical,writesAllowed() == false, logger gates (case-3 machinery),batches_gated > 0; then thresholds far below →.okand flushing resumes. - log sink file mode: install to a fixture path with
max_size_mbtiny (write the size gate via a test-only override — ifinstalltakes cfg, a 1 MB minimum makes this slow; addpub fn installForTest(io, cfg, max_bytes_override)if needed and mark it test-only), emit lines past the limit →.1exists, live file small,max_fileshonored, thendeinstall. 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. - cache with real packets: build a response via
ResponseBuilder,classify+put, advancenow_s,get→ TTLs visibly decremented (re-parse), expiry at the boundary is a miss. - 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 4–5
repository, src/config/, or src/cli.zig. A needed change there is reported, not made.
Acceptance Criteria (Milestone 6 Complete)
zig build testexit 0 with all nine new files wired intosrc/tests.zig.zig build test -Dintegrationexit 0 including the 9 S8 cases and every prior milestone's.zig build crossstill 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.zigrejectsrate_window_seconds = 0(rule exists at validate.zig:221; covered by milestone-4 tests).DnsCache.get,RateLimiter.checkhave 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 --checkclean repo-wide; GPG-signed lowercase commit.
Anti-Requirements
- No handler or server wiring.
handler.zigkeeps 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.
BatchWriterowns its statements;db.zigstays 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.
installruns only frommain. - No epoll/timerfd/signalfd. Loops sleep on
Clock.Duration; Phase 7 owns lifecycle.
As built (S1–S7 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.