milestone 6: dns cache, rate limiting, query logging, disk monitoring and retention
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
# 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 4–5
|
||||
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 (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.
|
||||
@@ -195,3 +195,25 @@ verification name is an IP literal (DoT `tls://1.1.1.1:853`) always fails with
|
||||
as an iPAddress SAN — which is how Cloudflare and Quad9 issue theirs. A DoT
|
||||
upstream therefore needs a DNS `tls_name` for SNI + verification while dialing
|
||||
the IP; verifying by bare IP cannot work on stock 0.16.
|
||||
|
||||
## Phase 6 verifications (concurrency, disk, files)
|
||||
|
||||
- `std.Io.Queue(Elem)` exists (Io.zig:2184): bounded ring buffer over a caller-supplied array.
|
||||
`put(q, io, elems, min)` with `min = 0` never blocks and returns 0 when full (Io.zig:2218);
|
||||
`getOne` blocks until an item or `error.Closed` after `close(io)`. Elements are copied as raw
|
||||
bytes (`@ptrCast`, Io.zig:2189) — an element holding a slice transfers only the pointer, so
|
||||
queue elements must be self-contained values.
|
||||
- `std.Io.Condition` has NO `timedWait` (Io.zig:1653 — wait/waitUncancelable/signal/broadcast
|
||||
only). The timed primitive is `std.Io.Event.waitTimeout` (Io.zig:1827).
|
||||
- Disk free space: std has NO statvfs/statfs wrapper (no Statfs struct, no `f_bavail` anywhere in
|
||||
lib/std). Options: `extern fn statvfs` against libc (we always link libc for sqlite) or raw
|
||||
`std.os.linux.syscall2(.statfs, ...)` with a hand-written struct.
|
||||
- File append mode does not exist on `Dir.OpenFileOptions`/`CreateFileOptions`. Append pattern:
|
||||
`openFile(io, path, .{ .mode = .write_only })`, `file.writer(io, &buf)`, then
|
||||
`w.seekTo(try file.length(io))`; the writer is positional. `File.setLength` truncates;
|
||||
`Dir.rename` + `Dir.deleteFile` rotate. Positional writes do not serialize — one task owns the
|
||||
log writer.
|
||||
- No fixed-capacity hash map in std. Bounded cache shape: fixed slot array plus
|
||||
`StringHashMapUnmanaged(u32)` from key to slot index (`ensureTotalCapacity` once at init);
|
||||
a CLOCK hand walks the stable slot array, never the map. Any map modification invalidates
|
||||
live iterators (hash_map.zig:496).
|
||||
|
||||
Reference in New Issue
Block a user