milestone 28: query provenance — every logged query is exactly explainable
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s

query rows gain qclass, rcode, group, policy action and reason, the
matched rule or list entry with its source, cname and safe-search
targets, route kind, forward zone, and the resolver that actually
answered — the pool and local markers die. servfails are logged and
name the resolver that lost; post-parse protocol refusals become rows.
a detail page at /queries/:id renders the ordered explanation, and
coverage watermarks distinguish an empty history from a missing one.

the schema fingerprint changes: existing query history is recreated
with the old file kept aside and the reset filed as a resolved
diagnostic. fixes an oversized udp reply being rebuilt as noerror,
which handed clients a truncated nxdomain as success.
This commit is contained in:
2026-08-22 09:16:40 +02:00
parent 7e6cb507d2
commit 0fd6bbd312
65 changed files with 7036 additions and 685 deletions
+153 -5
View File
@@ -22,8 +22,20 @@ const db = @import("db.zig");
const log = std.log.scoped(.querylog_schema);
/// Verbatim from PLAN §11.3. Multi-statement text — it goes through
/// `db.Db.exec`, never through `prepare`.
/// PLAN §11.3, plus the coverage watermark of milestone 28. Multi-statement
/// text — it goes through `db.Db.exec`, never through `prepare`.
///
/// The trailing INSERT seeds `querylog_meta`, which is part of the schema
/// rather than a later step: a `query_log` with no watermark beside it cannot
/// answer whether an empty result means "no queries" or "no history", and every
/// database this program reads from is created by executing this string.
/// `unixepoch()` is SQLite's own UTC clock, which is the clock every
/// `timestamp` in the file is measured against.
///
/// `available_since` starts one second *after* `created_at` on purpose. A row
/// logged in the same second the file was created is not evidence that the
/// second is completely covered, and the watermark's whole job is to be
/// conservative. From there it only ever advances, in `queries_repo.pruneOlderThan`.
pub const ddl: [:0]const u8 =
\\CREATE TABLE domains (
\\ id INTEGER PRIMARY KEY,
@@ -37,10 +49,23 @@ pub const ddl: [:0]const u8 =
\\ client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts
\\ qtype INTEGER,
\\ blocked INTEGER NOT NULL,
\\ block_reason TEXT,
\\ response_time_us INTEGER,
\\ cache_hit INTEGER,
\\ upstream TEXT
\\ upstream TEXT,
\\ qclass INTEGER NOT NULL,
\\ rcode INTEGER NOT NULL,
\\ group_id INTEGER, -- text/id pairs, not FKs: a renamed
\\ group_name TEXT, -- group must not rewrite history
\\ policy_action TEXT NOT NULL,
\\ policy_reason TEXT NOT NULL,
\\ matched TEXT,
\\ source_id INTEGER,
\\ source_name TEXT,
\\ cname_target TEXT,
\\ safe_search_target TEXT,
\\ route_kind TEXT NOT NULL,
\\ forward_zone TEXT,
\\ CHECK (rcode BETWEEN 0 AND 4095) -- twelve bits (RFC 6891 6.1.3)
\\);
\\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
@@ -63,6 +88,14 @@ pub const ddl: [:0]const u8 =
\\ CHECK (failures >= 0)
\\) WITHOUT ROWID;
\\CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
\\
\\CREATE TABLE querylog_meta (
\\ id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema
\\ created_at INTEGER NOT NULL,
\\ available_since INTEGER NOT NULL
\\);
\\INSERT INTO querylog_meta (id, created_at, available_since)
\\VALUES (1, unixepoch(), unixepoch() + 1);
;
/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
@@ -299,7 +332,7 @@ test "ddl creates the query-log tables, the upstream-history tables and every in
try database.exec(ddl);
try testing.expectEqual(
@as(i64, 4),
@as(i64, 5),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
const objects = [_][]const u8{
@@ -307,6 +340,7 @@ test "ddl creates the query-log tables, the upstream-history tables and every in
"idx_query_log_ts", "idx_query_log_client",
"idx_query_log_domain", "upstream_targets",
"upstream_minute", "idx_upstream_minute_ts",
"querylog_meta",
};
for (objects) |name| {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
@@ -317,6 +351,69 @@ test "ddl creates the query-log tables, the upstream-history tables and every in
}
}
test "the schema refuses an rcode outside twelve bits" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(ddl);
try database.exec("INSERT INTO domains (id, domain) VALUES (1, 'a.example');");
var stmt = try database.prepare(
\\INSERT INTO query_log
\\ (timestamp, domain_id, client_ip, blocked, qclass, rcode,
\\ policy_action, policy_reason, route_kind)
\\VALUES (1, 1, '10.0.0.1', 0, 1, ?1, 'not_evaluated', 'no_match', 'upstream')
);
defer stmt.deinit();
// The whole range an EDNS extended RCODE can express, and nothing wider:
// the producers are `u12`, and this is what stops any other writer — a
// hand-run UPDATE included — from putting a value in the column that the
// read path would have to reject.
for ([_]i64{ 0, 4095 }) |accepted| {
try stmt.reset();
try stmt.bindInt(1, accepted);
try stmt.exec();
}
for ([_]i64{ -1, 4096, 65535 }) |refused| {
// `sqlite3_reset` repeats the error of the statement it is resetting,
// which for every iteration after the first is the constraint failure
// this loop just asserted — the same reason `BatchWriter.resetAll`
// discards it.
stmt.reset() catch {};
try stmt.bindInt(1, refused);
try testing.expectError(error.Constraint, stmt.exec());
}
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log"));
}
test "querylog_meta is seeded with one row the schema will not let a second join" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(ddl);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));
const created = try database.queryInt("SELECT created_at FROM querylog_meta");
const since = try database.queryInt("SELECT available_since FROM querylog_meta");
// Conservative by exactly one second: a row logged in the creating second
// must not let a query claim that second is completely covered.
try testing.expectEqual(created + 1, since);
try testing.expect(created > 1_700_000_000);
// `CHECK (id = 1)` is what makes "the singleton row" a schema fact rather
// than a convention the read path has to defend against.
try testing.expectError(error.Constraint, database.exec(
"INSERT INTO querylog_meta (id, created_at, available_since) VALUES (2, 1, 1);",
));
try testing.expectError(error.Constraint, database.exec(
"INSERT INTO querylog_meta (id, created_at, available_since) VALUES (1, 1, 1);",
));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));
}
test "the user_version statement stamps the fingerprint" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
@@ -394,6 +491,57 @@ test "a recreate returns the aside name by value and a fresh create returns none
try tmp.dir.access(io, kept, .{});
}
test "a recreate resets coverage to the new file and keeps the old one aside" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
var path_buf: [path_buf_len]u8 = undefined;
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
var created = try open(io, std.Io.Dir.cwd(), path);
const first_coverage = try created.database.queryInt("SELECT available_since FROM querylog_meta");
// A row in the file the operator is about to lose.
try created.database.exec("INSERT INTO domains (domain) VALUES ('old.example');");
created.database.close();
// A healthy file this build's DDL no longer matches — the case milestone
// 28's own schema edit produces on every upgrade.
{
var stamped = try db.Db.open(path, .{ .mode = .read_write_existing });
defer stamped.close();
var sql_buf: [64]u8 = undefined;
try stamped.exec(try std.fmt.bufPrintZ(&sql_buf, "PRAGMA user_version = {d};", .{fingerprint +% 1}));
}
var recreated = try open(io, std.Io.Dir.cwd(), path);
defer recreated.database.close();
try testing.expectEqual(RecreateReason.fingerprint_mismatch, recreated.recreated.?);
// The name says the file was healthy and this build moved, not that it rotted.
try testing.expect(std.mem.indexOf(u8, recreated.aside(), ".schema-changed-") != null);
try tmp.dir.access(io, std.fs.path.basename(recreated.aside()), .{});
// Exactly one meta row, and coverage starts at the recreate rather than
// carrying the replaced file's promise forward.
try testing.expectEqual(
@as(i64, 1),
try recreated.database.queryInt("SELECT count(*) FROM querylog_meta"),
);
const new_coverage = try recreated.database.queryInt("SELECT available_since FROM querylog_meta");
try testing.expect(new_coverage >= first_coverage);
// Nothing of the old file came across: the history is genuinely gone, which
// is what the coverage start has to tell the operator.
try testing.expectEqual(
@as(i64, 0),
try recreated.database.queryInt("SELECT count(*) FROM domains"),
);
}
test "a clean reopen reports no recreate and no aside" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();