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
+528 -72
View File
@@ -19,20 +19,42 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const provenance = @import("../provenance.zig");
/// One `query_log` row. The logger applies the privacy transforms of PLAN
/// §11.4 before it builds this, so `domain` and `client_ip` are already
/// §11.4 before it builds this, so every domain-bearing field is already
/// whatever the operator agreed to store.
///
/// A `null` text field is a fact the query did not have — no upstream was
/// attempted, no rule matched, no CNAME was uncloaked — and reaches the column
/// as NULL. The three closed enums have no such state: every logged query has a
/// policy verdict, a reason for it and a route, even when the verdict is
/// "not evaluated".
pub const Row = struct {
timestamp: i64,
domain: []const u8,
client_ip: []const u8,
qtype: ?u16,
qclass: u16,
/// Twelve bits: the EDNS extended RCODE the client saw. The column's
/// `CHECK` bounds it to the same range, so a value this type cannot hold
/// is one the schema would have refused anyway.
rcode: u12,
blocked: bool,
block_reason: ?[]const u8,
response_time_us: ?i64,
cache_hit: ?bool,
upstream: ?[]const u8,
group_id: ?i64,
group_name: ?[]const u8,
policy_action: provenance.PolicyAction,
policy_reason: provenance.PolicyReason,
matched: ?[]const u8,
source_id: ?i64,
source_name: ?[]const u8,
cname_target: ?[]const u8,
safe_search_target: ?[]const u8,
route_kind: provenance.RouteKind,
forward_zone: ?[]const u8,
};
const insert_domain_sql = "INSERT OR IGNORE INTO domains (domain) VALUES (?1)";
@@ -41,9 +63,13 @@ const select_domain_sql = "SELECT id FROM domains WHERE domain = ?1";
const insert_row_sql =
\\INSERT INTO query_log
\\ (timestamp, domain_id, client_ip, qtype, blocked, block_reason,
\\ response_time_us, cache_hit, upstream)
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
\\ (timestamp, domain_id, client_ip, qtype, blocked,
\\ response_time_us, cache_hit, upstream, qclass, rcode,
\\ group_id, group_name, policy_action, policy_reason, matched,
\\ source_id, source_name, cname_target, safe_search_target,
\\ route_kind, forward_zone)
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
\\ ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
;
/// Owns the prepared statements of the flush loop. Init once, reuse per batch.
@@ -123,10 +149,22 @@ pub const BatchWriter = struct {
try stmt.bindText(3, row.client_ip);
try bindIntOrNull(stmt, 4, if (row.qtype) |v| @as(i64, v) else null);
try stmt.bindBool(5, row.blocked);
try stmt.bindTextOrNull(6, row.block_reason);
try bindIntOrNull(stmt, 7, row.response_time_us);
try bindIntOrNull(stmt, 8, if (row.cache_hit) |v| @as(i64, @intFromBool(v)) else null);
try stmt.bindTextOrNull(9, row.upstream);
try bindIntOrNull(stmt, 6, row.response_time_us);
try bindIntOrNull(stmt, 7, if (row.cache_hit) |v| @as(i64, @intFromBool(v)) else null);
try stmt.bindTextOrNull(8, row.upstream);
try stmt.bindInt(9, row.qclass);
try stmt.bindInt(10, row.rcode);
try bindIntOrNull(stmt, 11, row.group_id);
try stmt.bindTextOrNull(12, row.group_name);
try stmt.bindText(13, @tagName(row.policy_action));
try stmt.bindText(14, @tagName(row.policy_reason));
try stmt.bindTextOrNull(15, row.matched);
try bindIntOrNull(stmt, 16, row.source_id);
try stmt.bindTextOrNull(17, row.source_name);
try stmt.bindTextOrNull(18, row.cname_target);
try stmt.bindTextOrNull(19, row.safe_search_target);
try stmt.bindText(20, @tagName(row.route_kind));
try stmt.bindTextOrNull(21, row.forward_zone);
try stmt.exec();
}
@@ -144,17 +182,55 @@ fn bindIntOrNull(stmt: *db.Stmt, idx: c_int, value: ?i64) db.Error!void {
return stmt.bindNull(idx);
}
/// Deletes every `query_log` row strictly older than `cutoff_ts` and returns
/// how many went.
/// What one prune did, and where coverage now begins.
pub const PruneResult = struct {
deleted: i64,
/// The watermark after the prune, which is what a later `availableSince`
/// will return. Handed back so the caller need not re-read it.
available_since: i64,
};
/// Deletes every `query_log` row strictly older than `cutoff_ts` and advances
/// the coverage watermark to the same cutoff, in one transaction.
///
/// Orphaned `domains` rows stay: it is a dimension table, re-interning a name
/// costs one indexed insert, and §11.3 asks for no collection.
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
var stmt = try database.prepare("DELETE FROM query_log WHERE timestamp < ?1");
defer stmt.deinit();
try stmt.bindInt(1, cutoff_ts);
try stmt.exec();
return database.changes();
/// **The two are one operation, not two.** The watermark is the promise that
/// every query since it is still in the file; a delete that commits without the
/// advance breaks that promise, and an advance that commits without the delete
/// hides rows the file still holds. Either failure rolls both back, and the
/// caller retries the whole thing on its next pass.
///
/// The watermark never moves backward: `max` is what makes a prune with a
/// cutoff older than the file's own creation a no-op on it rather than a
/// regression. Orphaned `domains` rows stay — it is a dimension table,
/// re-interning a name costs one indexed insert, and §11.3 asks for no
/// collection.
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!PruneResult {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
var deleting = try database.prepare("DELETE FROM query_log WHERE timestamp < ?1");
defer deleting.deinit();
try deleting.bindInt(1, cutoff_ts);
try deleting.exec();
const deleted = database.changes();
var advancing = try database.prepare(
"UPDATE querylog_meta SET available_since = max(available_since, ?1) WHERE id = 1",
);
defer advancing.deinit();
try advancing.bindInt(1, cutoff_ts);
try advancing.exec();
const watermark = try database.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1");
try tx.commit();
return .{ .deleted = deleted, .available_since = watermark };
}
/// The oldest timestamp this file can still answer for. A query window that
/// starts before it is incomplete, and the API says so rather than charting the
/// gap as zero.
pub fn availableSince(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1");
}
/// `PRAGMA wal_checkpoint(TRUNCATE)`: moves the WAL into the database and
@@ -189,21 +265,60 @@ pub fn countDomains(database: *db.Db) db.Error!i64 {
/// One row of `GET /api/queries`, joined back through the `domains` dimension.
///
/// `block_reason` and `upstream` are nullable columns, and a NULL reads as `""`
/// the same convention `Stmt.columnText` already uses. Neither column is ever
/// written as an empty string (a reason is a word, an upstream is a URL), so the
/// mapping loses nothing and the API layer can treat `""` as "absent".
/// A summary projection, deliberately narrower than `QueryDetail`: the list is
/// a table the operator scans, and the full provenance of a row is one request
/// away at `GET /api/queries/{id}`.
///
/// The nullable text columns read a NULL as `""` — the same convention
/// `Stmt.columnText` already uses. None of them is ever written as an empty
/// string, so the mapping loses nothing and the API layer can treat `""` as
/// "absent".
pub const QueryRow = struct {
id: i64,
ts: i64,
domain: []const u8,
client_ip: []const u8,
qtype: ?u16,
qclass: u16,
rcode: u12,
blocked: bool,
block_reason: []const u8,
response_time_us: ?i64,
cache_hit: ?bool,
upstream: []const u8,
policy_action: provenance.PolicyAction,
policy_reason: provenance.PolicyReason,
route_kind: provenance.RouteKind,
};
/// Everything one `query_log` row records about one query, for
/// `GET /api/queries/{id}`.
///
/// Same NULL-reads-as-`""` convention as `QueryRow`, and the same closed enums:
/// a stored value the schema does not define is `error.Mismatch`, never passed
/// through as text.
pub const QueryDetail = struct {
id: i64,
ts: i64,
domain: []const u8,
client_ip: []const u8,
qtype: ?u16,
qclass: u16,
rcode: u12,
blocked: bool,
response_time_us: ?i64,
cache_hit: ?bool,
upstream: []const u8,
group_id: ?i64,
group_name: []const u8,
policy_action: provenance.PolicyAction,
policy_reason: provenance.PolicyReason,
matched: []const u8,
source_id: ?i64,
source_name: []const u8,
cname_target: []const u8,
safe_search_target: []const u8,
route_kind: provenance.RouteKind,
forward_zone: []const u8,
};
/// Every field is an independent narrowing; `null` means "do not filter on it".
@@ -230,7 +345,8 @@ pub const max_limit: u32 = 1000;
const select_head =
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
\\ q.block_reason, q.response_time_us, q.cache_hit, q.upstream
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
\\ q.policy_action, q.policy_reason, q.route_kind
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
;
@@ -337,15 +453,81 @@ pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db
.qtype = if (stmt.isNull(4)) null else std.math.cast(u16, stmt.columnInt(4)) orelse
return error.Mismatch,
.blocked = stmt.columnBool(5),
.block_reason = try stmt.columnTextAlloc(arena, 6),
.response_time_us = if (stmt.isNull(7)) null else stmt.columnInt(7),
.cache_hit = if (stmt.isNull(8)) null else stmt.columnBool(8),
.upstream = try stmt.columnTextAlloc(arena, 9),
.response_time_us = if (stmt.isNull(6)) null else stmt.columnInt(6),
.cache_hit = if (stmt.isNull(7)) null else stmt.columnBool(7),
.upstream = try stmt.columnTextAlloc(arena, 8),
.qclass = try columnU16(&stmt, 9),
.rcode = try columnU12(&stmt, 10),
.policy_action = try provenance.parse(provenance.PolicyAction, stmt.columnText(11)),
.policy_reason = try provenance.parse(provenance.PolicyReason, stmt.columnText(12)),
.route_kind = try provenance.parse(provenance.RouteKind, stmt.columnText(13)),
});
}
return out;
}
/// A `NOT NULL` integer column that the schema bounds to 16 bits. A value
/// outside that range means the row came from something other than this schema.
fn columnU16(stmt: *db.Stmt, col: c_int) db.Error!u16 {
return std.math.cast(u16, stmt.columnInt(col)) orelse error.Mismatch;
}
/// The `rcode` column, which the schema's `CHECK` bounds to twelve bits. This
/// build cannot write a wider value — the field is a `u12` all the way from the
/// handler — so a row that carries one was written by something else, and is
/// `error.Mismatch` rather than a value truncated into shape.
fn columnU12(stmt: *db.Stmt, col: c_int) db.Error!u12 {
return std.math.cast(u12, stmt.columnInt(col)) orelse error.Mismatch;
}
const select_detail_sql =
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
\\ q.group_id, q.group_name, q.policy_action, q.policy_reason,
\\ q.matched, q.source_id, q.source_name, q.cname_target,
\\ q.safe_search_target, q.route_kind, q.forward_zone
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
\\ WHERE q.id = ?1
;
/// One row's full provenance, or `null` when no row has that id — which is what
/// an id the operator kept from before a retention pass looks like, and is a
/// 404 rather than an error.
///
/// Every string is allocated from `arena`, on the same terms as
/// `selectQueries`.
pub fn detailById(database: *db.Db, arena: Allocator, id: i64) db.Error!?QueryDetail {
var stmt = try database.prepare(select_detail_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return .{
.id = stmt.columnInt(0),
.ts = stmt.columnInt(1),
.domain = try stmt.columnTextAlloc(arena, 2),
.client_ip = try stmt.columnTextAlloc(arena, 3),
.qtype = if (stmt.isNull(4)) null else try columnU16(&stmt, 4),
.blocked = stmt.columnBool(5),
.response_time_us = if (stmt.isNull(6)) null else stmt.columnInt(6),
.cache_hit = if (stmt.isNull(7)) null else stmt.columnBool(7),
.upstream = try stmt.columnTextAlloc(arena, 8),
.qclass = try columnU16(&stmt, 9),
.rcode = try columnU12(&stmt, 10),
.group_id = if (stmt.isNull(11)) null else stmt.columnInt(11),
.group_name = try stmt.columnTextAlloc(arena, 12),
.policy_action = try provenance.parse(provenance.PolicyAction, stmt.columnText(13)),
.policy_reason = try provenance.parse(provenance.PolicyReason, stmt.columnText(14)),
.matched = try stmt.columnTextAlloc(arena, 15),
.source_id = if (stmt.isNull(16)) null else stmt.columnInt(16),
.source_name = try stmt.columnTextAlloc(arena, 17),
.cname_target = try stmt.columnTextAlloc(arena, 18),
.safe_search_target = try stmt.columnTextAlloc(arena, 19),
.route_kind = try provenance.parse(provenance.RouteKind, stmt.columnText(20)),
.forward_zone = try stmt.columnTextAlloc(arena, 21),
};
}
/// Wraps `needle` in `%` and neutralises the two `LIKE` metacharacters, so a
/// user searching for `a_b` gets domains containing `a_b` and not domains
/// containing `axb`. The escape character escapes itself.
@@ -493,11 +675,23 @@ fn plainRow(timestamp: i64, domain: []const u8) Row {
.domain = domain,
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = false,
.block_reason = null,
.response_time_us = 1200,
.cache_hit = false,
.upstream = "9.9.9.9",
.group_id = 1,
.group_name = "default",
.policy_action = .allow,
.policy_reason = .no_match,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = .upstream,
.forward_zone = null,
};
}
@@ -509,6 +703,43 @@ fn domainIdOf(database: *db.Db, domain: []const u8) !i64 {
return stmt.columnInt(0);
}
test "a foreign row with an rcode wider than twelve bits is refused, not truncated" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
// The shipped schema's `CHECK` makes this row impossible in a file this
// build created, so the table is built without it. The read path's job is
// to refuse a `querylog.db` that came from somewhere else rather than to
// narrow a value it cannot represent.
try database.exec(
\\CREATE TABLE domains (id INTEGER PRIMARY KEY, domain TEXT NOT NULL UNIQUE);
\\CREATE TABLE query_log (
\\ id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL,
\\ domain_id INTEGER NOT NULL, client_ip TEXT NOT NULL,
\\ qtype INTEGER, blocked INTEGER NOT NULL, response_time_us INTEGER,
\\ cache_hit INTEGER, upstream TEXT, qclass INTEGER NOT NULL,
\\ rcode INTEGER NOT NULL, group_id INTEGER, group_name TEXT,
\\ 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
\\);
\\INSERT INTO domains (id, domain) VALUES (1, 'a.example');
\\INSERT INTO query_log
\\ (id, timestamp, domain_id, client_ip, blocked, qclass, rcode,
\\ policy_action, policy_reason, route_kind)
\\VALUES (1, 10, 1, '192.0.2.10', 0, 1, 4096, 'allow', 'no_match', 'upstream');
);
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectError(error.Mismatch, selectQueries(&database, arena, .{}));
try testing.expectError(error.Mismatch, detailById(&database, arena, 1));
}
test "writeBatch inserts every row and interns each domain once" {
var database = try openLog();
defer database.close();
@@ -553,7 +784,7 @@ test "a second batch reuses the interned domain id" {
);
}
test "nullable columns round-trip a value and a null" {
test "every column round-trips a value and a null" {
var database = try openLog();
defer database.close();
var writer = try BatchWriter.init(&database);
@@ -565,54 +796,125 @@ test "nullable columns round-trip a value and a null" {
.domain = "blocked.example",
.client_ip = "2001:db8::1",
.qtype = 28,
.qclass = 1,
.rcode = 3,
.blocked = true,
.block_reason = "blocklist",
.response_time_us = 42,
.cache_hit = true,
.upstream = "dns.example",
.upstream = "https://dns.example/dns-query",
.group_id = 7,
.group_name = "kids",
.policy_action = .block,
.policy_reason = .blocklist_wildcard,
.matched = "*.ads.example",
.source_id = 3,
.source_name = "steven black",
.cname_target = "tracker.cdn.example",
.safe_search_target = "forcesafesearch.google.com",
.route_kind = .blocked,
.forward_zone = "home.arpa",
},
// Every nullable column absent at once, which is the shape of a query
// the pipeline answered before any of them applied.
.{
.timestamp = 11,
.domain = "quiet.example",
.client_ip = "hidden",
.qtype = null,
.qclass = 3,
.rcode = 0,
.blocked = false,
.block_reason = null,
.response_time_us = null,
.cache_hit = null,
.upstream = null,
.group_id = null,
.group_name = null,
.policy_action = .not_evaluated,
.policy_reason = .non_in_class,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = .upstream,
.forward_zone = null,
},
});
var stmt = try database.prepare(
\\SELECT d.domain, q.client_ip, q.qtype, q.blocked, q.block_reason,
\\ q.response_time_us, q.cache_hit, q.upstream
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
\\ ORDER BY q.timestamp
);
defer stmt.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expect(try stmt.step());
try testing.expectEqualStrings("blocked.example", stmt.columnText(0));
try testing.expectEqualStrings("2001:db8::1", stmt.columnText(1));
try testing.expectEqual(@as(i64, 28), stmt.columnInt(2));
try testing.expect(stmt.columnBool(3));
try testing.expectEqualStrings("blocklist", stmt.columnText(4));
try testing.expectEqual(@as(i64, 42), stmt.columnInt(5));
try testing.expect(stmt.columnBool(6));
try testing.expectEqualStrings("dns.example", stmt.columnText(7));
const full = (try detailById(&database, arena, 1)).?;
try testing.expectEqualStrings("blocked.example", full.domain);
try testing.expectEqualStrings("2001:db8::1", full.client_ip);
try testing.expectEqual(@as(?u16, 28), full.qtype);
try testing.expectEqual(@as(u16, 1), full.qclass);
try testing.expectEqual(@as(u12, 3), full.rcode);
try testing.expect(full.blocked);
try testing.expectEqual(@as(?i64, 42), full.response_time_us);
try testing.expectEqual(@as(?bool, true), full.cache_hit);
try testing.expectEqualStrings("https://dns.example/dns-query", full.upstream);
try testing.expectEqual(@as(?i64, 7), full.group_id);
try testing.expectEqualStrings("kids", full.group_name);
try testing.expectEqual(provenance.PolicyAction.block, full.policy_action);
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, full.policy_reason);
try testing.expectEqualStrings("*.ads.example", full.matched);
try testing.expectEqual(@as(?i64, 3), full.source_id);
try testing.expectEqualStrings("steven black", full.source_name);
try testing.expectEqualStrings("tracker.cdn.example", full.cname_target);
try testing.expectEqualStrings("forcesafesearch.google.com", full.safe_search_target);
try testing.expectEqual(provenance.RouteKind.blocked, full.route_kind);
try testing.expectEqualStrings("home.arpa", full.forward_zone);
try testing.expect(try stmt.step());
try testing.expectEqualStrings("quiet.example", stmt.columnText(0));
try testing.expectEqualStrings("hidden", stmt.columnText(1));
try testing.expect(stmt.isNull(2));
try testing.expect(!stmt.columnBool(3));
try testing.expect(stmt.isNull(4));
try testing.expect(stmt.isNull(5));
try testing.expect(stmt.isNull(6));
try testing.expect(stmt.isNull(7));
// A NULL text column reads as the empty string, by the documented
// convention; a NULL integer stays null, because 0 is a real id.
const bare = (try detailById(&database, arena, 2)).?;
try testing.expectEqual(@as(?u16, null), bare.qtype);
try testing.expectEqual(@as(u16, 3), bare.qclass);
try testing.expectEqual(@as(?i64, null), bare.response_time_us);
try testing.expectEqual(@as(?bool, null), bare.cache_hit);
try testing.expectEqualStrings("", bare.upstream);
try testing.expectEqual(@as(?i64, null), bare.group_id);
try testing.expectEqualStrings("", bare.group_name);
try testing.expectEqual(provenance.PolicyAction.not_evaluated, bare.policy_action);
try testing.expectEqual(provenance.PolicyReason.non_in_class, bare.policy_reason);
try testing.expectEqualStrings("", bare.matched);
try testing.expectEqual(@as(?i64, null), bare.source_id);
try testing.expectEqualStrings("", bare.source_name);
try testing.expectEqualStrings("", bare.cname_target);
try testing.expectEqualStrings("", bare.safe_search_target);
try testing.expectEqual(provenance.RouteKind.upstream, bare.route_kind);
try testing.expectEqualStrings("", bare.forward_zone);
}
try testing.expect(!try stmt.step());
test "detailById returns null for an id no row has" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try seed(&database, &.{plainRow(10, "a.example")});
// An id from before a retention pass looks exactly like this, and is a 404
// rather than an error.
try testing.expectEqual(@as(?QueryDetail, null), try detailById(&database, arena_state.allocator(), 2));
try testing.expectEqual(@as(?QueryDetail, null), try detailById(&database, arena_state.allocator(), 0));
try testing.expect((try detailById(&database, arena_state.allocator(), 1)) != null);
}
test "a stored enum value the schema does not define is a data error, not a passthrough" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{plainRow(10, "a.example")});
try database.exec("UPDATE query_log SET policy_reason = 'whatever' WHERE id = 1;");
try testing.expectError(error.Mismatch, detailById(&database, arena, 1));
try testing.expectError(error.Mismatch, selectQueries(&database, arena, .{}));
}
test "an empty batch writes nothing and opens no transaction" {
@@ -644,7 +946,7 @@ test "pruneOlderThan deletes strictly older rows and returns the count" {
plainRow(300, "fresh.example"),
});
try testing.expectEqual(@as(i64, 2), try pruneOlderThan(&database, 200));
try testing.expectEqual(@as(i64, 2), (try pruneOlderThan(&database, 200)).deleted);
try testing.expectEqual(@as(i64, 2), try countRows(&database));
// The row exactly at the cutoff stays.
try testing.expectEqual(
@@ -652,7 +954,123 @@ test "pruneOlderThan deletes strictly older rows and returns the count" {
try database.queryInt("SELECT count(*) FROM query_log WHERE timestamp = 200"),
);
// A second pass over the same cutoff finds nothing left to do.
try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 200));
try testing.expectEqual(@as(i64, 0), (try pruneOlderThan(&database, 200)).deleted);
}
test "a prune advances the coverage watermark to its own cutoff" {
var database = try openLog();
defer database.close();
var writer = try BatchWriter.init(&database);
defer writer.deinit();
// The seeded watermark is `created_at + 1`, which is now-ish; the cutoffs
// below are all in the past, so they start out behind it.
const start = try availableSince(&database);
try writer.writeBatch(&.{ plainRow(start + 100, "a.example"), plainRow(start + 300, "b.example") });
const first = try pruneOlderThan(&database, start + 200);
try testing.expectEqual(@as(i64, 1), first.deleted);
try testing.expectEqual(start + 200, first.available_since);
try testing.expectEqual(start + 200, try availableSince(&database));
// A prune that deletes nothing still advances: the window it swept is
// covered whether or not it held rows.
const second = try pruneOlderThan(&database, start + 250);
try testing.expectEqual(@as(i64, 0), second.deleted);
try testing.expectEqual(start + 250, try availableSince(&database));
}
test "the watermark never moves backward" {
var database = try openLog();
defer database.close();
const start = try availableSince(&database);
const advanced = try pruneOlderThan(&database, start + 1000);
try testing.expectEqual(start + 1000, advanced.available_since);
// A shortened `retention_days`, a clock that stepped back, a pass with a
// stale cutoff: none of them may widen the promise the file makes.
for ([_]i64{ start + 999, start, start - 100_000, 0 }) |older| {
const result = try pruneOlderThan(&database, older);
try testing.expectEqual(start + 1000, result.available_since);
try testing.expectEqual(start + 1000, try availableSince(&database));
}
}
test "a failed delete leaves both the rows and the watermark untouched" {
var database = try openLog();
defer database.close();
var writer = try BatchWriter.init(&database);
defer writer.deinit();
const start = try availableSince(&database);
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
try database.exec(
\\CREATE TRIGGER refuse_delete BEFORE DELETE ON query_log
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
try testing.expectEqual(@as(i64, 1), try countRows(&database));
try testing.expectEqual(start, try availableSince(&database));
}
test "a failed watermark update leaves the rows it had already deleted" {
var database = try openLog();
defer database.close();
var writer = try BatchWriter.init(&database);
defer writer.deinit();
const start = try availableSince(&database);
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
// The delete succeeds and the advance does not. Without one transaction
// around the pair, this is the case that loses rows the watermark still
// promises.
try database.exec(
\\CREATE TRIGGER refuse_advance BEFORE UPDATE ON querylog_meta
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
try testing.expectEqual(@as(i64, 1), try countRows(&database));
try testing.expectEqual(start, try availableSince(&database));
}
test "a failed commit rolls back the delete and the watermark together" {
var database = try openLog();
defer database.close();
var writer = try BatchWriter.init(&database);
defer writer.deinit();
const start = try availableSince(&database);
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
// Both statements succeed and COMMIT is what fails: the advance inserts a
// `query_log` row whose `domain_id` references nothing, and
// `defer_foreign_keys` holds that violation back until the commit checks
// it (SQLite's documented semantics for the pragma; the assertions below
// observe the rollback, not the moment the check ran).
try database.exec(
\\CREATE TRIGGER break_at_commit AFTER UPDATE ON querylog_meta
\\BEGIN INSERT INTO query_log
\\ (timestamp, domain_id, client_ip, blocked, qclass, rcode,
\\ policy_action, policy_reason, route_kind)
\\VALUES (1, 999999, 'x', 0, 1, 0, 'allow', 'no_match', 'upstream'); END;
);
try database.exec("PRAGMA defer_foreign_keys = ON;");
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
// Nothing survived: not the delete, not the advance, not the row the
// trigger inserted.
try testing.expectEqual(@as(i64, 1), try countRows(&database));
try testing.expectEqual(start, try availableSince(&database));
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT count(*) FROM query_log WHERE client_ip = 'x'"),
);
}
test "pruneOlderThan leaves the domains dimension table intact" {
@@ -662,7 +1080,7 @@ test "pruneOlderThan leaves the domains dimension table intact" {
defer writer.deinit();
try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(11, "b.example") });
try testing.expectEqual(@as(i64, 2), try pruneOlderThan(&database, 1000));
try testing.expectEqual(@as(i64, 2), (try pruneOlderThan(&database, 1000)).deleted);
try testing.expectEqual(@as(i64, 0), try countRows(&database));
try testing.expectEqual(@as(i64, 2), try countDomains(&database));
@@ -749,7 +1167,7 @@ test "checkpointTruncate and vacuum run against a WAL file database" {
try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(20, "b.example") });
try checkpointTruncate(&database);
try testing.expectEqual(@as(i64, 1), try pruneOlderThan(&database, 20));
try testing.expectEqual(@as(i64, 1), (try pruneOlderThan(&database, 20)).deleted);
try checkpointTruncate(&database);
try vacuum(&database);
@@ -785,22 +1203,46 @@ test "selectQueries returns the newest row first and reads every column" {
.domain = "ads.example.net",
.client_ip = "192.0.2.10",
.qtype = 28,
.qclass = 1,
.rcode = 0,
.blocked = true,
.block_reason = "blocklist",
.response_time_us = 4200,
.cache_hit = true,
.upstream = "https://dns.example/dns-query",
.group_id = 2,
.group_name = "kids",
.policy_action = .block,
.policy_reason = .blocklist_domain,
.matched = "ads.example.net",
.source_id = 5,
.source_name = "steven black",
.cname_target = null,
.safe_search_target = null,
.route_kind = .blocked,
.forward_zone = null,
},
.{
.timestamp = 20,
.domain = "quiet.example",
.client_ip = "hidden",
.qtype = null,
.qclass = 1,
.rcode = 2,
.blocked = false,
.block_reason = null,
.response_time_us = null,
.cache_hit = null,
.upstream = null,
.group_id = null,
.group_name = null,
.policy_action = .not_evaluated,
.policy_reason = .snapshot_unavailable,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = .rejected,
.forward_zone = null,
},
});
@@ -813,12 +1255,16 @@ test "selectQueries returns the newest row first and reads every column" {
try testing.expectEqualStrings("quiet.example", newest.domain);
try testing.expectEqualStrings("hidden", newest.client_ip);
try testing.expectEqual(@as(?u16, null), newest.qtype);
try testing.expectEqual(@as(u16, 1), newest.qclass);
try testing.expectEqual(@as(u12, 2), newest.rcode);
try testing.expect(!newest.blocked);
// A NULL text column reads as the empty string, by documented convention.
try testing.expectEqualStrings("", newest.block_reason);
try testing.expectEqual(@as(?i64, null), newest.response_time_us);
try testing.expectEqual(@as(?bool, null), newest.cache_hit);
// A NULL text column reads as the empty string, by documented convention.
try testing.expectEqualStrings("", newest.upstream);
try testing.expectEqual(provenance.PolicyAction.not_evaluated, newest.policy_action);
try testing.expectEqual(provenance.PolicyReason.snapshot_unavailable, newest.policy_reason);
try testing.expectEqual(provenance.RouteKind.rejected, newest.route_kind);
const oldest = rows.items[1];
try testing.expectEqual(@as(i64, 1), oldest.id);
@@ -826,11 +1272,15 @@ test "selectQueries returns the newest row first and reads every column" {
try testing.expectEqualStrings("ads.example.net", oldest.domain);
try testing.expectEqualStrings("192.0.2.10", oldest.client_ip);
try testing.expectEqual(@as(?u16, 28), oldest.qtype);
try testing.expectEqual(@as(u16, 1), oldest.qclass);
try testing.expectEqual(@as(u12, 0), oldest.rcode);
try testing.expect(oldest.blocked);
try testing.expectEqualStrings("blocklist", oldest.block_reason);
try testing.expectEqual(@as(?i64, 4200), oldest.response_time_us);
try testing.expectEqual(@as(?bool, true), oldest.cache_hit);
try testing.expectEqualStrings("https://dns.example/dns-query", oldest.upstream);
try testing.expectEqual(provenance.PolicyAction.block, oldest.policy_action);
try testing.expectEqual(provenance.PolicyReason.blocklist_domain, oldest.policy_reason);
try testing.expectEqual(provenance.RouteKind.blocked, oldest.route_kind);
}
test "selectQueries honours the limit and caps it at max_limit" {
@@ -895,7 +1345,9 @@ test "each filter narrows the result on its own" {
var blocked_row = plainRow(200, "ads.example.net");
blocked_row.client_ip = "192.0.2.20";
blocked_row.blocked = true;
blocked_row.block_reason = "blocklist";
blocked_row.policy_action = .block;
blocked_row.policy_reason = .blocklist_domain;
blocked_row.route_kind = .blocked;
try seed(&database, &.{
plainRow(100, "one.example.com"),
blocked_row,
@@ -1004,7 +1456,9 @@ test "statsTotals aggregates the window and averages only the timed rows" {
timed.response_time_us = 100;
var blocked_row = plainRow(150, "ads.example");
blocked_row.blocked = true;
blocked_row.block_reason = "blocklist";
blocked_row.policy_action = .block;
blocked_row.policy_reason = .blocklist_domain;
blocked_row.route_kind = .blocked;
blocked_row.response_time_us = 200;
var cached = plainRow(199, "b.example");
cached.client_ip = "192.0.2.99";
@@ -1042,7 +1496,9 @@ test "timeseries writes every bucket, including the ones with no rows" {
var blocked_row = plainRow(1020, "ads.example");
blocked_row.blocked = true;
blocked_row.block_reason = "blocklist";
blocked_row.policy_action = .block;
blocked_row.policy_reason = .blocklist_domain;
blocked_row.route_kind = .blocked;
var cached = plainRow(1035, "b.example");
cached.cache_hit = true;
try seed(&database, &.{