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
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user