//! `query_log` and its `domains` dimension table in `querylog.db`. //! //! Two shapes live here. The free functions follow the milestone-4 repository //! idiom — prepare, use, finalize — because retention runs them a handful of //! times per day, and the API read layer at the bottom of the file runs once //! per HTTP request. The flush loop is the one hot path in the program, so it //! gets `BatchWriter`, which owns its three statements for its whole life //! (`db.zig:360` names this file as the reason `db.zig` carries no statement //! cache). //! //! Every string in a `Row` is borrowed for the duration of the call only: //! `Stmt.bindText` binds with `SQLITE_TRANSIENT`, so SQLite copies before //! `writeBatch` returns. //! //! The rows are expendable log data. Nothing here retries, and the caller //! decides what a failed batch means. 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 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, 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, }; /// The projection grain of the four `bucket_*` tables, in seconds. /// /// 1800 divides every serving width the Overview offers at or above half an /// hour (1800, 3600, 21600), which is what lets one grain answer the 24h, 7d /// and 30d windows exactly. The 1h window is 60-second buckets and is served /// from the raw rows instead. pub const grain: i64 = 1800; /// Floors a timestamp onto the projection grid. /// /// `@divFloor`, never `@divTrunc` or SQLite's `/`: both truncate toward zero, /// which for a negative timestamp names the bucket *after* the one the row /// belongs to. Every producer and every consumer of a `bucket` value in this /// file goes through this function or an equivalent floor expression. pub fn bucketOf(timestamp: i64) i64 { return @divFloor(timestamp, grain) * grain; } /// `bucket_types.qtype` is NOT NULL, so the rows that carry no query type need /// a value of their own. No qtype is a `u16`, so -1 cannot collide with one. pub const null_qtype: i64 = -1; /// The answering resolver's identity, as the routes breakdown defines it: the /// upstream url for `upstream` rows, the zone for `forward_zone` rows, null /// everywhere else. The Zig twin of `overview_raw_sql`'s CASE, and the only /// place the writer decides what a route's source is. fn routeSourceOf(row: Row) ?[]const u8 { return switch (row.route_kind) { .upstream => row.upstream, .forward_zone => row.forward_zone, else => null, }; } const insert_domain_sql = "INSERT OR IGNORE INTO domains (domain) VALUES (?1)"; 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, \\ 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) ; /// One `bucket_totals` row's worth of change from a single batch. const TotalsDelta = struct { queries: i64 = 0, blocked: i64 = 0, cached: i64 = 0, rt_sum: i64 = 0, rt_count: i64 = 0, }; /// The delta-map keys borrow the batch's strings. They live only from the /// aggregation pass to the end of the `writeBatch` that produced them, which is /// inside the borrow `Row` already documents. const ClientKey = struct { bucket: i64, client_ip: []const u8 }; const TypeKey = struct { bucket: i64, qtype: i64 }; const RouteKey = struct { bucket: i64, route: provenance.RouteKind, source: ?[]const u8 }; const ClientKeyContext = struct { pub fn hash(_: ClientKeyContext, key: ClientKey) u64 { var hasher: std.hash.Wyhash = .init(@bitCast(key.bucket)); hasher.update(key.client_ip); return hasher.final(); } pub fn eql(_: ClientKeyContext, a: ClientKey, b: ClientKey) bool { return a.bucket == b.bucket and std.mem.eql(u8, a.client_ip, b.client_ip); } }; const RouteKeyContext = struct { pub fn hash(_: RouteKeyContext, key: RouteKey) u64 { var hasher: std.hash.Wyhash = .init(@bitCast(key.bucket)); hasher.update(@tagName(key.route)); hasher.update(&[_]u8{@intFromBool(key.source != null)}); hasher.update(key.source orelse ""); return hasher.final(); } pub fn eql(_: RouteKeyContext, a: RouteKey, b: RouteKey) bool { return a.bucket == b.bucket and a.route == b.route and sameSource(a.source, b.source); } }; const load_percentage = std.hash_map.default_max_load_percentage; const upsert_totals_sql = \\INSERT INTO bucket_totals (bucket, queries, blocked, cached, rt_sum, rt_count) \\VALUES (?1, ?2, ?3, ?4, ?5, ?6) \\ON CONFLICT(bucket) DO UPDATE SET \\ queries = queries + excluded.queries, \\ blocked = blocked + excluded.blocked, \\ cached = cached + excluded.cached, \\ rt_sum = rt_sum + excluded.rt_sum, \\ rt_count = rt_count + excluded.rt_count ; const upsert_clients_sql = \\INSERT INTO bucket_clients (bucket, client_ip, queries) \\VALUES (?1, ?2, ?3) \\ON CONFLICT(bucket, client_ip) DO UPDATE SET queries = queries + excluded.queries ; const upsert_types_sql = \\INSERT INTO bucket_types (bucket, qtype, count) \\VALUES (?1, ?2, ?3) \\ON CONFLICT(bucket, qtype) DO UPDATE SET count = count + excluded.count ; const upsert_routes_sql = \\INSERT INTO bucket_routes (bucket, route_kind, source_present, source_text, count) \\VALUES (?1, ?2, ?3, ?4, ?5) \\ON CONFLICT(bucket, route_kind, source_present, source_text) \\DO UPDATE SET count = count + excluded.count ; /// Owns the prepared statements of the flush loop. Init once, reuse per batch. /// /// `database` must outlive the writer and must not move: every `Stmt` holds a /// `*Db`. Neither `Db` nor `Stmt` is thread-safe, so one writer belongs to one /// task. /// /// The writer also maintains the four `bucket_*` projections, in the same /// transaction as the rows they summarise. `gpa` is owned for the writer's life /// and is used for nothing but the per-batch delta maps, whose capacity is /// retained across batches and whose contents are dropped at the end of every /// `writeBatch`. pub const BatchWriter = struct { gpa: Allocator, database: *db.Db, insert_domain: db.Stmt, select_domain: db.Stmt, insert_row: db.Stmt, upsert_totals: db.Stmt, upsert_clients: db.Stmt, upsert_types: db.Stmt, upsert_routes: db.Stmt, totals_deltas: std.AutoHashMapUnmanaged(i64, TotalsDelta) = .empty, client_deltas: std.HashMapUnmanaged(ClientKey, i64, ClientKeyContext, load_percentage) = .empty, type_deltas: std.AutoHashMapUnmanaged(TypeKey, i64) = .empty, route_deltas: std.HashMapUnmanaged(RouteKey, i64, RouteKeyContext, load_percentage) = .empty, pub fn init(gpa: Allocator, database: *db.Db) db.Error!BatchWriter { var insert_domain = try database.prepare(insert_domain_sql); errdefer insert_domain.deinit(); var select_domain = try database.prepare(select_domain_sql); errdefer select_domain.deinit(); var insert_row = try database.prepare(insert_row_sql); errdefer insert_row.deinit(); var upsert_totals = try database.prepare(upsert_totals_sql); errdefer upsert_totals.deinit(); var upsert_clients = try database.prepare(upsert_clients_sql); errdefer upsert_clients.deinit(); var upsert_types = try database.prepare(upsert_types_sql); errdefer upsert_types.deinit(); const upsert_routes = try database.prepare(upsert_routes_sql); return .{ .gpa = gpa, .database = database, .insert_domain = insert_domain, .select_domain = select_domain, .insert_row = insert_row, .upsert_totals = upsert_totals, .upsert_clients = upsert_clients, .upsert_types = upsert_types, .upsert_routes = upsert_routes, }; } pub fn deinit(self: *BatchWriter) void { self.route_deltas.deinit(self.gpa); self.type_deltas.deinit(self.gpa); self.client_deltas.deinit(self.gpa); self.totals_deltas.deinit(self.gpa); self.upsert_routes.deinit(); self.upsert_types.deinit(); self.upsert_clients.deinit(); self.upsert_totals.deinit(); self.insert_row.deinit(); self.select_domain.deinit(); self.insert_domain.deinit(); } /// One transaction for the whole batch: the raw rows and every projection /// they touch commit together or not at all. Domains are interned through /// `INSERT OR IGNORE` followed by `SELECT id`. /// /// On any failure the transaction rolls back, so a batch is all or /// nothing, and the writer stays usable for the next batch. pub fn writeBatch(self: *BatchWriter, rows: []const Row) db.Error!void { if (rows.len == 0) return; // Declared before the aggregation so it runs after every `errdefer` // below, and placed before `BEGIN` so an `error.OutOfMemory` fails the // batch with no transaction outstanding. defer self.clearDeltas(); try self.aggregate(rows); var tx = try db.Tx.begin(self.database); // `errdefer`s run in reverse: the statements are released before the // ROLLBACK, so no read cursor is still open when it runs. errdefer tx.rollback(); errdefer self.resetAll(); for (rows) |row| { const domain_id = try self.internDomain(row.domain); try self.write(row, domain_id); } try self.applyProjections(); try tx.commit(); } /// Folds the batch into per-key deltas, so the transaction below runs one /// UPSERT per touched key rather than per row. Any slice length: the /// logger's 100-row batching is its own policy, not this API's. fn aggregate(self: *BatchWriter, rows: []const Row) Allocator.Error!void { for (rows) |row| { const bucket = bucketOf(row.timestamp); const totals = try self.totals_deltas.getOrPut(self.gpa, bucket); if (!totals.found_existing) totals.value_ptr.* = .{}; totals.value_ptr.queries += 1; if (row.blocked) totals.value_ptr.blocked += 1; if (row.cache_hit orelse false) totals.value_ptr.cached += 1; if (row.response_time_us) |us| { totals.value_ptr.rt_sum += us; totals.value_ptr.rt_count += 1; } try bump(&self.client_deltas, self.gpa, ClientKey{ .bucket = bucket, .client_ip = row.client_ip, }); try bump(&self.type_deltas, self.gpa, TypeKey{ .bucket = bucket, .qtype = if (row.qtype) |v| @as(i64, v) else null_qtype, }); try bump(&self.route_deltas, self.gpa, RouteKey{ .bucket = bucket, .route = row.route_kind, .source = routeSourceOf(row), }); } } /// One more row on `key`. `map` is any of the three count maps — they /// differ only in their key type and its hashing. fn bump(map: anytype, gpa: Allocator, key: anytype) Allocator.Error!void { const entry = try map.getOrPut(gpa, key); if (!entry.found_existing) entry.value_ptr.* = 0; entry.value_ptr.* += 1; } fn applyProjections(self: *BatchWriter) db.Error!void { var totals = self.totals_deltas.iterator(); while (totals.next()) |entry| { const stmt = &self.upsert_totals; try stmt.reset(); try stmt.bindInt(1, entry.key_ptr.*); try stmt.bindInt(2, entry.value_ptr.queries); try stmt.bindInt(3, entry.value_ptr.blocked); try stmt.bindInt(4, entry.value_ptr.cached); try stmt.bindInt(5, entry.value_ptr.rt_sum); try stmt.bindInt(6, entry.value_ptr.rt_count); try stmt.exec(); } var clients = self.client_deltas.iterator(); while (clients.next()) |entry| { const stmt = &self.upsert_clients; try stmt.reset(); try stmt.bindInt(1, entry.key_ptr.bucket); try stmt.bindText(2, entry.key_ptr.client_ip); try stmt.bindInt(3, entry.value_ptr.*); try stmt.exec(); } var types = self.type_deltas.iterator(); while (types.next()) |entry| { const stmt = &self.upsert_types; try stmt.reset(); try stmt.bindInt(1, entry.key_ptr.bucket); try stmt.bindInt(2, entry.key_ptr.qtype); try stmt.bindInt(3, entry.value_ptr.*); try stmt.exec(); } var routes = self.route_deltas.iterator(); while (routes.next()) |entry| { const stmt = &self.upsert_routes; try stmt.reset(); try stmt.bindInt(1, entry.key_ptr.bucket); try stmt.bindText(2, @tagName(entry.key_ptr.route)); try stmt.bindInt(3, @intFromBool(entry.key_ptr.source != null)); try stmt.bindText(4, entry.key_ptr.source orelse ""); try stmt.bindInt(5, entry.value_ptr.*); try stmt.exec(); } } /// Capacity is retained; only the entries go. The keys borrow the batch's /// strings, so nothing may outlive the `writeBatch` that filled them. fn clearDeltas(self: *BatchWriter) void { self.totals_deltas.clearRetainingCapacity(); self.client_deltas.clearRetainingCapacity(); self.type_deltas.clearRetainingCapacity(); self.route_deltas.clearRetainingCapacity(); } fn internDomain(self: *BatchWriter, domain: []const u8) db.Error!i64 { try self.insert_domain.reset(); try self.insert_domain.bindText(1, domain); try self.insert_domain.exec(); try self.select_domain.reset(); try self.select_domain.bindText(1, domain); // The insert above either created the row or found it already there, // so a miss means the table changed under this connection. if (!try self.select_domain.step()) return error.NotFound; const id = self.select_domain.columnInt(0); // A statement stopped on a row keeps its cursor open until it is // reset; the transaction must not carry that to the next row. try self.select_domain.reset(); return id; } fn write(self: *BatchWriter, row: Row, domain_id: i64) db.Error!void { var stmt = &self.insert_row; try stmt.reset(); try stmt.bindInt(1, row.timestamp); try stmt.bindInt(2, domain_id); 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 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(); } /// Best effort: this runs on the failure path, where the error that /// matters is the one already on its way to the caller. fn resetAll(self: *BatchWriter) void { self.upsert_routes.reset() catch {}; self.upsert_types.reset() catch {}; self.upsert_clients.reset() catch {}; self.upsert_totals.reset() catch {}; self.insert_row.reset() catch {}; self.select_domain.reset() catch {}; self.insert_domain.reset() catch {}; } }; fn bindIntOrNull(stmt: *db.Stmt, idx: c_int, value: ?i64) db.Error!void { if (value) |v| return stmt.bindInt(idx, v); return stmt.bindNull(idx); } /// 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. /// /// **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. /// /// The four projections are pruned in the same transaction. Buckets entirely /// before the cutoff's own bucket are deleted outright; a cutoff that does not /// land on the grid leaves one straddling bucket, which is recomputed from the /// rows that survived rather than reduced by an estimate. Any failure in any of /// that rolls back the raw delete and the watermark advance with it. 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(); const grid = bucketOf(cutoff_ts); for (delete_before_bucket_sql) |sql| { var stmt = try database.prepare(sql); defer stmt.deinit(); try stmt.bindInt(1, grid); try stmt.exec(); } // On the grid there is nothing partial to fix: the delete above took whole // buckets and the loop took their projection rows. if (cutoff_ts != grid) try recomputeBucket(database, grid); 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 }; } const delete_before_bucket_sql = [_][]const u8{ "DELETE FROM bucket_totals WHERE bucket < ?1", "DELETE FROM bucket_clients WHERE bucket < ?1", "DELETE FROM bucket_types WHERE bucket < ?1", "DELETE FROM bucket_routes WHERE bucket < ?1", }; const delete_one_bucket_sql = [_][]const u8{ "DELETE FROM bucket_totals WHERE bucket = ?1", "DELETE FROM bucket_clients WHERE bucket = ?1", "DELETE FROM bucket_types WHERE bucket = ?1", "DELETE FROM bucket_routes WHERE bucket = ?1", }; /// `?1` is the bucket start and `?2` its exclusive end. Each statement rebuilds /// one projection table's rows for that bucket from the raw rows still in it. /// /// `HAVING count(*) > 0` on the totals statement is what keeps an emptied /// bucket from being written back as a row of zeros: a bare aggregate always /// produces one row, and a from-scratch recomputation produces none. The other /// three group by a key, so an empty range already yields nothing. const recompute_bucket_sql = [_][]const u8{ \\INSERT INTO bucket_totals (bucket, queries, blocked, cached, rt_sum, rt_count) \\SELECT ?1, count(*), coalesce(sum(blocked <> 0), 0), coalesce(sum(cache_hit = 1), 0), \\ coalesce(sum(response_time_us), 0), count(response_time_us) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 \\HAVING count(*) > 0 , \\INSERT INTO bucket_clients (bucket, client_ip, queries) \\SELECT ?1, client_ip, count(*) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 \\ GROUP BY client_ip , \\INSERT INTO bucket_types (bucket, qtype, count) \\SELECT ?1, coalesce(qtype, -1), count(*) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 \\ GROUP BY coalesce(qtype, -1) , \\INSERT INTO bucket_routes (bucket, route_kind, source_present, source_text, count) \\SELECT ?1, route_kind, source IS NOT NULL, coalesce(source, ''), count(*) \\ FROM (SELECT route_kind, \\ CASE route_kind \\ WHEN 'upstream' THEN upstream \\ WHEN 'forward_zone' THEN forward_zone \\ END AS source \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2) \\ GROUP BY route_kind, source , }; /// Replaces one bucket's projection rows with a recomputation from the raw rows /// that are still in it. The caller owns the transaction. fn recomputeBucket(database: *db.Db, bucket: i64) db.Error!void { for (delete_one_bucket_sql) |sql| { var stmt = try database.prepare(sql); defer stmt.deinit(); try stmt.bindInt(1, bucket); try stmt.exec(); } for (recompute_bucket_sql) |sql| { var stmt = try database.prepare(sql); defer stmt.deinit(); try stmt.bindInt(1, bucket); try stmt.bindInt(2, bucket + grain); try stmt.exec(); } } /// 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 /// truncates it to zero bytes, which is what keeps a day of log writes from /// growing the WAL past the free space the disk monitor watches. /// /// SQLite reports a checkpoint blocked by a concurrent reader in the row it /// returns, not as an error code, so a blocked checkpoint is not an error /// here. Retention checkpoints after every prune, so the next pass retries. /// On a database that is not in WAL mode the pragma is a no-op. pub fn checkpointTruncate(database: *db.Db) db.Error!void { return database.exec("PRAGMA wal_checkpoint(TRUNCATE);"); } /// Rewrites the whole file. Retention runs this rarely by design — on an SD /// card a full rewrite is the most expensive thing this program does. pub fn vacuum(database: *db.Db) db.Error!void { return database.exec("VACUUM;"); } pub fn countRows(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM query_log"); } pub fn countDomains(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM domains"); } // --------------------------------------------------------------------------- // the API read layer (`GET /api/queries`, `GET /api/overview`) // --------------------------------------------------------------------------- /// One row of `GET /api/queries`, joined back through the `domains` dimension. /// /// 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, 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". /// /// `since` is inclusive and `until` is exclusive, so adjacent windows tile /// without double-counting a row on the boundary. pub const QueryFilter = struct { limit: u32 = 100, /// Keyset cursor: only rows with a strictly smaller `id`. Rows come back /// newest-first, so this is the id of the last row of the previous page. before: ?i64 = null, /// Matched case-insensitively for ASCII, which is what SQLite's `LIKE` /// does and what a domain search wants. domain_substring: ?[]const u8 = null, client: ?[]const u8 = null, blocked: ?bool = null, since: ?i64 = null, until: ?i64 = null, }; /// Ruling 11 caps the page at 1000; the repository enforces it too, so a caller /// that forgets cannot ask this connection for the whole table. pub const max_limit: u32 = 1000; const select_head = \\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.policy_action, q.policy_reason, q.route_kind \\ FROM query_log q JOIN domains d ON d.id = q.domain_id ; /// The escape character of `where_domain`. SQLite does not give string literals /// C escapes, so `'\'` in the SQL text is one backslash. const like_escape = '\\'; const where_before = " q.id < ?"; const where_domain = " d.domain LIKE ? ESCAPE '\\'"; const where_client = " q.client_ip = ?"; const where_blocked = " q.blocked = ?"; const where_since = " q.timestamp >= ?"; const where_until = " q.timestamp < ?"; const select_tail = " ORDER BY q.id DESC LIMIT ?"; const where_keyword = " WHERE"; const and_keyword = " AND"; /// Assembles the statement from the fixed fragments above and nothing else. /// /// **No value ever reaches this buffer.** Every filter contributes a `?` and is /// bound afterwards, in the order the predicates were appended: an unnumbered /// parameter takes the next free index, so append order and bind order are the /// same single contract. const Sql = struct { /// `where_keyword` is longer than `and_keyword` and is used at most once, /// so counting six of it bounds every reachable combination. const capacity = select_head.len + 6 * where_keyword.len + select_tail.len + where_before.len + where_domain.len + where_client.len + where_blocked.len + where_since.len + where_until.len; buf: [capacity]u8 = undefined, len: usize = 0, has_where: bool = false, fn put(self: *Sql, fragment: []const u8) void { @memcpy(self.buf[self.len..][0..fragment.len], fragment); self.len += fragment.len; } fn predicate(self: *Sql, fragment: []const u8) void { self.put(if (self.has_where) and_keyword else where_keyword); self.has_where = true; self.put(fragment); } fn text(self: *const Sql) []const u8 { return self.buf[0..self.len]; } }; /// Rows come back newest-first (`id DESC`). Every string is allocated from /// `arena`, including the list's own storage, so the caller frees the whole /// result by resetting the arena — there is nothing to unwind on failure. pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) { var sql: Sql = .{}; sql.put(select_head); if (filter.before != null) sql.predicate(where_before); if (filter.domain_substring != null) sql.predicate(where_domain); if (filter.client != null) sql.predicate(where_client); if (filter.blocked != null) sql.predicate(where_blocked); if (filter.since != null) sql.predicate(where_since); if (filter.until != null) sql.predicate(where_until); sql.put(select_tail); var stmt = try database.prepare(sql.text()); defer stmt.deinit(); var idx: c_int = 0; if (filter.before) |v| { idx += 1; try stmt.bindInt(idx, v); } if (filter.domain_substring) |v| { idx += 1; try stmt.bindText(idx, try likePattern(arena, v)); } if (filter.client) |v| { idx += 1; try stmt.bindText(idx, v); } if (filter.blocked) |v| { idx += 1; try stmt.bindBool(idx, v); } if (filter.since) |v| { idx += 1; try stmt.bindInt(idx, v); } if (filter.until) |v| { idx += 1; try stmt.bindInt(idx, v); } idx += 1; try stmt.bindInt(idx, @min(filter.limit, max_limit)); var out: std.ArrayList(QueryRow) = .empty; while (try stmt.step()) { try out.append(arena, .{ .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 std.math.cast(u16, stmt.columnInt(4)) orelse return error.Mismatch, .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), .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. fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8 { var out: std.ArrayList(u8) = try .initCapacity(arena, needle.len * 2 + 2); out.appendAssumeCapacity('%'); for (needle) |ch| { if (ch == '%' or ch == '_' or ch == like_escape) out.appendAssumeCapacity(like_escape); out.appendAssumeCapacity(ch); } out.appendAssumeCapacity('%'); return out.items; } /// The Overview rollup for one window. `avg_response_time_us` is `null` when no /// row in the window recorded a response time. The mean is derived from a sum /// and a count rather than SQL's `avg`, which returns REAL: `Stmt` reads /// integers, and integer microseconds are exact. pub const StatsTotals = struct { queries: u64, blocked: u64, distinct_clients: u64, avg_response_time_us: ?i64, }; /// `count` and `sum` over non-negative columns cannot go negative; a negative /// value means the row came from something other than this schema. fn countOf(value: i64) db.Error!u64 { if (value < 0) return error.Mismatch; return @intCast(value); } /// One bucket of the Overview timeseries. `ts` is the bucket's inclusive start. pub const Bucket = struct { ts: i64, queries: u64, blocked: u64, cached: u64, }; /// One row of the Overview type breakdown. `qtype` is nullable in the schema, so /// the rows that carry no type group into a row of their own rather than /// disappearing from a breakdown that claims to add up. /// /// No name field: the only qtype-name table lives in the admin, and a second /// copy here would drift out of agreement with it. /// /// The list carries no zero rows: a type absent from the window is absent from /// it. Its order is an order, not an identity — a caller keys on the `qtype` /// value, never on a row's position, because a rank change between refreshes /// moves rows and must not move what they mean. pub const TypeCount = struct { qtype: ?u16, count: u64, }; /// One row of the Overview route breakdown: how a slice of the window was /// answered. /// /// `source` is the answering resolver's identity and nothing else — the /// upstream url for `upstream` rows, the zone for `forward_zone` rows, null /// everywhere else. It is deliberately not `source_name`, which names the /// blocklist a block came from and would read as an upstream here. /// /// A null source on an `upstream` row is its own group, not a dropped row: it /// is a real state of the log and the caller labels it. pub const RouteCount = struct { route: provenance.RouteKind, source: ?[]const u8, count: u64, }; /// How many clients the Overview names before the rest become `other`. Eight is /// what one legend can carry without becoming a second table. pub const max_client_series = 8; /// One named client's series. `buckets` is always the caller's bucket count /// long, zero-filled, and aligned exactly like `Overview.buckets`. pub const ClientSeries = struct { client: []const u8, buckets: []const u64, }; /// Named clients are ranked by in-window total, ties broken by address, so the /// cut at `max_client_series` is the same cut on every read over the same data. /// /// `other` is always present and always bucket-count sized — including for an /// empty window and for a window with eight clients or fewer. A caller charting /// a stack must not have to invent the residual series. pub const ClientsBreakdown = struct { clients: []const ClientSeries, other: []const u64, }; fn zeroedBuckets(arena: Allocator, bucket_count: u32) Allocator.Error![]u64 { const buckets = try arena.alloc(u64, bucket_count); @memset(buckets, 0); return buckets; } // --------------------------------------------------------------------------- // the Overview read path (milestone 36) // --------------------------------------------------------------------------- /// Everything one Overview response reports about one window, from one read of /// one already-open transaction. /// /// Storage owns these scalars: `since`, `bucket_seconds` and `bucket_count` are /// numbers, not a web module's period type, so nothing here has to know the /// period grammar the handler enforces. pub const Overview = struct { totals: StatsTotals, /// `bucket_count` entries, one per bucket of the window, zero-filled and /// aligned to `since`. buckets: []const Bucket, clients: ClientsBreakdown, types: []const TypeCount, routes: []const RouteCount, }; /// One client's running window total beside its per-bucket series, so the cut /// at `max_client_series` can be made after the whole window is folded rather /// than by a second pass over the data. const ClientAccum = struct { client: []const u8, total: u64, buckets: []u64, }; /// The shared shape both paths fold into. Every allocation is from `arena`, so /// there is nothing to unwind on failure. const Accum = struct { arena: Allocator, since: i64, width: i64, bucket_count: u32, buckets: []Bucket, rt_sum: i64 = 0, rt_count: i64 = 0, clients: std.ArrayList(ClientAccum) = .empty, /// `client_ip` to its index in `clients`. The names are arena copies, so /// they outlive the statement that produced them. client_index: std.StringHashMapUnmanaged(u32) = .empty, types: std.ArrayList(TypeCount) = .empty, type_index: std.AutoHashMapUnmanaged(i64, u32) = .empty, /// Household cardinality: a handful of upstreams plus six route kinds. A /// linear scan is the whole index this needs. routes: std.ArrayList(RouteCount) = .empty, fn init(arena: Allocator, since: i64, width: i64, bucket_count: u32) Allocator.Error!Accum { const buckets = try arena.alloc(Bucket, bucket_count); for (buckets, 0..) |*bucket, i| bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0, }; return .{ .arena = arena, .since = since, .width = width, .bucket_count = bucket_count, .buckets = buckets }; } /// The serving-bucket index a grid bucket or a raw timestamp falls in. /// Both paths bound their reads by the window already; the check is what /// keeps a schema surprise from writing past the slice. fn indexOf(self: *const Accum, timestamp: i64) db.Error!usize { const offset = @divFloor(timestamp - self.since, self.width); const index = std.math.cast(usize, offset) orelse return error.Mismatch; if (index >= self.buckets.len) return error.Mismatch; return index; } fn addTotals(self: *Accum, index: usize, queries: u64, blocked: u64, cached: u64) void { self.buckets[index].queries += queries; self.buckets[index].blocked += blocked; self.buckets[index].cached += cached; } fn addClient(self: *Accum, client: []const u8, index: usize, count: u64) Allocator.Error!void { const slot = try self.client_index.getOrPut(self.arena, client); if (!slot.found_existing) { const owned = try self.arena.dupe(u8, client); slot.key_ptr.* = owned; slot.value_ptr.* = @intCast(self.clients.items.len); try self.clients.append(self.arena, .{ .client = owned, .total = 0, .buckets = try zeroedBuckets(self.arena, self.bucket_count), }); } const accum = &self.clients.items[slot.value_ptr.*]; accum.total += count; accum.buckets[index] += count; } /// `qtype` is the stored encoding: `null_qtype` for the rows that carry no /// query type. fn addType(self: *Accum, qtype: i64, count: u64) db.Error!void { const slot = try self.type_index.getOrPut(self.arena, qtype); if (!slot.found_existing) { slot.value_ptr.* = @intCast(self.types.items.len); try self.types.append(self.arena, .{ .qtype = if (qtype == null_qtype) null else std.math.cast(u16, qtype) orelse return error.Mismatch, .count = 0, }); } self.types.items[slot.value_ptr.*].count += count; } fn addRoute(self: *Accum, route: provenance.RouteKind, source: ?[]const u8, count: u64) Allocator.Error!void { for (self.routes.items) |*existing| { if (existing.route == route and sameSource(existing.source, source)) { existing.count += count; return; } } try self.routes.append(self.arena, .{ .route = route, .source = if (source) |s| try self.arena.dupe(u8, s) else null, .count = count, }); } fn finish(self: *Accum) Allocator.Error!Overview { var queries: u64 = 0; var blocked: u64 = 0; for (self.buckets) |bucket| { queries += bucket.queries; blocked += bucket.blocked; } // Every comparator below is a total order on a unique key, so an // unstable sort still produces one list for one window. std.sort.pdq(TypeCount, self.types.items, {}, typeBefore); std.sort.pdq(RouteCount, self.routes.items, {}, routeBefore); std.sort.pdq(ClientAccum, self.clients.items, {}, clientBefore); const named = @min(self.clients.items.len, max_client_series); const other = try zeroedBuckets(self.arena, self.bucket_count); for (self.clients.items[named..]) |folded| { for (other, folded.buckets) |*slot, count| slot.* += count; } const series = try self.arena.alloc(ClientSeries, named); for (series, self.clients.items[0..named]) |*entry, accum| { entry.* = .{ .client = accum.client, .buckets = accum.buckets }; } return .{ .totals = .{ .queries = queries, .blocked = blocked, .distinct_clients = self.clients.items.len, .avg_response_time_us = if (self.rt_count == 0) null else @divTrunc(self.rt_sum, self.rt_count), }, .buckets = self.buckets, .clients = .{ .clients = series, .other = other }, .types = self.types.items, .routes = self.routes.items, }; } }; /// Absent and present are different sources, and so are two different present /// ones: an `upstream` row the log recorded no resolver for is its own group, /// exactly as `GROUP BY ... source` makes it. fn sameSource(a: ?[]const u8, b: ?[]const u8) bool { const left = a orelse return b == null; const right = b orelse return false; return std.mem.eql(u8, left, right); } /// The oracle's `ORDER BY count(*) DESC, qtype IS NULL, qtype ASC`. fn typeBefore(_: void, a: TypeCount, b: TypeCount) bool { if (a.count != b.count) return a.count > b.count; const a_type = a.qtype orelse return false; const b_type = b.qtype orelse return true; return a_type < b_type; } /// The oracle's `ORDER BY count(*) DESC, route_kind ASC, source IS NULL, /// source ASC`. `route_kind` is compared as the stored text, not as the /// enum's declaration order — the two disagree, and the stored text is what the /// SQL sorted. fn routeBefore(_: void, a: RouteCount, b: RouteCount) bool { if (a.count != b.count) return a.count > b.count; switch (std.mem.order(u8, @tagName(a.route), @tagName(b.route))) { .lt => return true, .gt => return false, .eq => {}, } const a_source = a.source orelse return false; const b_source = b.source orelse return true; return std.mem.order(u8, a_source, b_source) == .lt; } /// The oracle's `ORDER BY count(*) DESC, client_ip ASC`, which is what makes /// the cut at `max_client_series` the same cut on every read. fn clientBefore(_: void, a: ClientAccum, b: ClientAccum) bool { if (a.total != b.total) return a.total > b.total; return std.mem.order(u8, a.client, b.client) == .lt; } const overview_projection_totals_sql = \\SELECT bucket, queries, blocked, cached, rt_sum, rt_count \\ FROM bucket_totals \\ WHERE bucket >= ?1 AND bucket < ?2 ; const overview_projection_clients_sql = \\SELECT bucket, client_ip, queries \\ FROM bucket_clients \\ WHERE bucket >= ?1 AND bucket < ?2 ; const overview_projection_types_sql = \\SELECT qtype, sum(count) \\ FROM bucket_types \\ WHERE bucket >= ?1 AND bucket < ?2 \\ GROUP BY qtype ; const overview_projection_routes_sql = \\SELECT route_kind, source_present, source_text, sum(count) \\ FROM bucket_routes \\ WHERE bucket >= ?1 AND bucket < ?2 \\ GROUP BY route_kind, source_present, source_text ; const overview_raw_sql = \\SELECT timestamp, client_ip, qtype, blocked, cache_hit, response_time_us, \\ route_kind, \\ CASE route_kind \\ WHEN 'upstream' THEN upstream \\ WHEN 'forward_zone' THEN forward_zone \\ END AS source \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 ; /// The whole Overview payload for `[since, since + bucket_seconds * /// bucket_count)`, from one already-open read transaction — the caller owns the /// transaction and the lock, as it does for every other read here. /// /// Two implementations behind one entry point. At or above the projection grain /// the four `bucket_*` tables answer the window, and the cost stops depending /// on how many raw rows it holds; below it (the 1h window, on 60-second /// buckets) one pass over the raw rows in the window does the same work in Zig. /// Both produce the same contracts, which is what the equivalence test asserts. /// /// Preconditions are caller bugs, not runtime conditions: a zero width or count /// is `error.Misuse`, and so is a projection-path window that is not on the /// grid, because the projections cannot express it. The handler's `window()` /// guarantees all of them. pub fn overview( database: *db.Db, arena: Allocator, since: i64, bucket_seconds: u32, bucket_count: u32, ) db.Error!Overview { if (bucket_seconds == 0 or bucket_count == 0) return error.Misuse; const width: i64 = bucket_seconds; const span = std.math.mul(i64, width, bucket_count) catch return error.Misuse; const until = std.math.add(i64, since, span) catch return error.Misuse; const from_projections = width >= grain; if (from_projections and (@mod(since, grain) != 0 or @rem(width, grain) != 0)) return error.Misuse; var accum = try Accum.init(arena, since, width, bucket_count); if (from_projections) { try readProjections(database, &accum, since, until); } else { try readRaw(database, &accum, since, until); } return try accum.finish(); } fn readProjections(database: *db.Db, accum: *Accum, since: i64, until: i64) db.Error!void { { var stmt = try database.prepare(overview_projection_totals_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { const index = try accum.indexOf(stmt.columnInt(0)); accum.addTotals( index, try countOf(stmt.columnInt(1)), try countOf(stmt.columnInt(2)), try countOf(stmt.columnInt(3)), ); accum.rt_sum += stmt.columnInt(4); accum.rt_count += stmt.columnInt(5); } } { var stmt = try database.prepare(overview_projection_clients_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { const index = try accum.indexOf(stmt.columnInt(0)); try accum.addClient(stmt.columnText(1), index, try countOf(stmt.columnInt(2))); } } { var stmt = try database.prepare(overview_projection_types_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { try accum.addType(stmt.columnInt(0), try countOf(stmt.columnInt(1))); } } { var stmt = try database.prepare(overview_projection_routes_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { const route = try provenance.parse(provenance.RouteKind, stmt.columnText(0)); const source: ?[]const u8 = if (stmt.columnInt(1) == 0) null else stmt.columnText(2); try accum.addRoute(route, source, try countOf(stmt.columnInt(3))); } } } fn readRaw(database: *db.Db, accum: *Accum, since: i64, until: i64) db.Error!void { var stmt = try database.prepare(overview_raw_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { const index = try accum.indexOf(stmt.columnInt(0)); const blocked: u64 = @intFromBool(stmt.columnBool(3)); // `cache_hit = 1`, not "non-zero": the SQL aggregates this replaces // compare against the literal, and a NULL compares to neither. const cached: u64 = @intFromBool(!stmt.isNull(4) and stmt.columnInt(4) == 1); accum.addTotals(index, 1, blocked, cached); if (!stmt.isNull(5)) { accum.rt_sum += stmt.columnInt(5); accum.rt_count += 1; } try accum.addClient(stmt.columnText(1), index, 1); try accum.addType(if (stmt.isNull(2)) null_qtype else stmt.columnInt(2), 1); try accum.addRoute( try provenance.parse(provenance.RouteKind, stmt.columnText(6)), stmt.columnTextOrNull(7), 1, ); } } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const logger = @import("../logger.zig"); const querylog_schema = @import("../querylog_schema.zig"); const testing = std.testing; fn openLog() !db.Db { var database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer database.close(); try db.applyPragmas(&database, .{}); try database.exec(querylog_schema.ddl); return database; } fn plainRow(timestamp: i64, domain: []const u8) Row { return .{ .timestamp = timestamp, .domain = domain, .client_ip = "192.0.2.10", .qtype = 1, .qclass = 1, .rcode = 0, .blocked = false, .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, }; } fn domainIdOf(database: *db.Db, domain: []const u8) !i64 { var stmt = try database.prepare(select_domain_sql); defer stmt.deinit(); try stmt.bindText(1, domain); try testing.expect(try stmt.step()); 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(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{ plainRow(100, "example.com"), plainRow(101, "example.com"), plainRow(102, "ads.example.net"), }); try testing.expectEqual(@as(i64, 3), try countRows(&database)); try testing.expectEqual(@as(i64, 2), try countDomains(&database)); const first = try domainIdOf(&database, "example.com"); try testing.expectEqual( @as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log WHERE domain_id = 1"), ); try testing.expectEqual(@as(i64, 1), first); } test "a second batch reuses the interned domain id" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{plainRow(100, "example.com")}); const before = try domainIdOf(&database, "example.com"); try writer.writeBatch(&.{ plainRow(200, "example.com"), plainRow(201, "other.example") }); const after = try domainIdOf(&database, "example.com"); try testing.expectEqual(before, after); try testing.expectEqual(@as(i64, 3), try countRows(&database)); try testing.expectEqual(@as(i64, 2), try countDomains(&database)); try testing.expectEqual( @as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log WHERE domain_id = 1"), ); } test "every column round-trips a value and a null" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{ .{ .timestamp = 10, .domain = "blocked.example", .client_ip = "2001:db8::1", .qtype = 28, .qclass = 1, .rcode = 3, .blocked = true, .response_time_us = 42, .cache_hit = true, .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, .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 arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); 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); // 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); } 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" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); // A transaction is already open, so a `BEGIN IMMEDIATE` from `writeBatch` // would fail: this is what proves the empty batch returns before it. var tx = try db.Tx.begin(&database); try writer.writeBatch(&.{}); tx.rollback(); try testing.expectEqual(@as(i64, 0), try countRows(&database)); try testing.expectEqual(@as(i64, 0), try countDomains(&database)); } test "pruneOlderThan deletes strictly older rows and returns the count" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{ plainRow(100, "old.example"), plainRow(199, "old.example"), plainRow(200, "edge.example"), plainRow(300, "fresh.example"), }); 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( @as(i64, 1), 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)).deleted); } test "a prune advances the coverage watermark to its own cutoff" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &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(testing.allocator, &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(testing.allocator, &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(testing.allocator, &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" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(11, "b.example") }); 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)); } test "a failing row rolls the whole batch back and the writer survives it" { var database = try openLog(); defer database.close(); try database.exec( \\CREATE TRIGGER refuse_boom BEFORE INSERT ON query_log \\WHEN new.client_ip = 'boom' \\BEGIN SELECT RAISE(ABORT, 'refused'); END; ); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); var doomed = plainRow(20, "second.example"); doomed.client_ip = "boom"; try testing.expectError(error.Constraint, writer.writeBatch(&.{ plainRow(10, "first.example"), doomed, })); // The interned domain of the row that did insert is gone with it. try testing.expectEqual(@as(i64, 0), try countRows(&database)); try testing.expectEqual(@as(i64, 0), try countDomains(&database)); try writer.writeBatch(&.{plainRow(30, "third.example")}); try testing.expectEqual(@as(i64, 1), try countRows(&database)); try testing.expectEqual(@as(i64, 1), try countDomains(&database)); } test "countRows and countDomains agree with what the batches wrote" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try testing.expectEqual(@as(i64, 0), try countRows(&database)); try testing.expectEqual(@as(i64, 0), try countDomains(&database)); var rows: [50]Row = undefined; var names: [50][16]u8 = undefined; for (&rows, &names, 0..) |*row, *name, i| { const written = std.fmt.bufPrint(name, "d{d}.example", .{i % 7}) catch unreachable; row.* = plainRow(@intCast(i), written); } try writer.writeBatch(&rows); try testing.expectEqual(@as(i64, 50), try countRows(&database)); try testing.expectEqual(@as(i64, 7), try countDomains(&database)); } // `PRAGMA wal_checkpoint` needs a real WAL, which an in-memory database cannot // have. `std.testing.tmpDir` creates its directory under `.zig-cache/tmp/` // relative to the process working directory, which is also how SQLite's VFS // resolves the filename it is handed (`storage_integration_test.zig:44`). const tmp_prefix = ".zig-cache/tmp/"; const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len; test "checkpointTruncate and vacuum run against a WAL file database" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/querylog.db", .{ tmp_prefix, &tmp.sub_path }); var database = try db.Db.open(path, .{ .mode = .read_write_create }); defer database.close(); try db.applyPragmas(&database, .{}); { var stmt = try database.prepare("PRAGMA journal_mode"); defer stmt.deinit(); try testing.expect(try stmt.step()); // `columnText` is borrowed until the next call on the statement, so it // is compared here rather than carried out of this block. try testing.expectEqualStrings("wal", stmt.columnText(0)); } try database.exec(querylog_schema.ddl); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(20, "b.example") }); try checkpointTruncate(&database); try testing.expectEqual(@as(i64, 1), (try pruneOlderThan(&database, 20)).deleted); try checkpointTruncate(&database); try vacuum(&database); try testing.expectEqual(@as(i64, 1), try countRows(&database)); try testing.expectEqual(@as(i64, 2), try countDomains(&database)); } // --- the read layer ------------------------------------------------------- /// `BatchWriter` assigns `query_log.id` in the order it is handed the rows, so /// every test below knows the id of each seeded row: the nth row of the nth /// batch has id n. fn seed(database: *db.Db, rows: []const Row) !void { var writer = try BatchWriter.init(testing.allocator, database); defer writer.deinit(); try writer.writeBatch(rows); } fn ids(rows: []const QueryRow, out: []i64) []const i64 { for (rows, 0..) |row, i| out[i] = row.id; return out[0..rows.len]; } test "selectQueries returns the newest row first and reads every column" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try seed(&database, &.{ .{ .timestamp = 10, .domain = "ads.example.net", .client_ip = "192.0.2.10", .qtype = 28, .qclass = 1, .rcode = 0, .blocked = true, .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, .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, }, }); const rows = try selectQueries(&database, arena_state.allocator(), .{}); try testing.expectEqual(@as(usize, 2), rows.items.len); const newest = rows.items[0]; try testing.expectEqual(@as(i64, 2), newest.id); try testing.expectEqual(@as(i64, 20), newest.ts); 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); 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); try testing.expectEqual(@as(i64, 10), oldest.ts); 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.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" { 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(); var rows: [1005]Row = undefined; for (&rows, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com"); try seed(&database, &rows); const few = try selectQueries(&database, arena, .{ .limit = 3 }); try testing.expectEqual(@as(usize, 3), few.items.len); // Asked for more than the cap, and for more rows than the cap, so the cap // is what bounds the answer rather than the table. const capped = try selectQueries(&database, arena, .{ .limit = 5000 }); try testing.expectEqual(@as(usize, max_limit), capped.items.len); const none = try selectQueries(&database, arena, .{ .limit = 0 }); try testing.expectEqual(@as(usize, 0), none.items.len); } test "keyset paging walks every row exactly once across the page boundaries" { 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(); var seeded: [7]Row = undefined; for (&seeded, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com"); try seed(&database, &seeded); var seen: std.ArrayList(i64) = .empty; defer seen.deinit(testing.allocator); var before: ?i64 = null; var pages: usize = 0; while (pages < 10) : (pages += 1) { const page = try selectQueries(&database, arena, .{ .limit = 3, .before = before }); if (page.items.len == 0) break; for (page.items) |row| try seen.append(testing.allocator, row.id); before = page.items[page.items.len - 1].id; } // Two full pages and one short page; the fourth call returns nothing and // breaks before the counter, which is how the walk knows it is done. try testing.expectEqual(@as(usize, 3), pages); try testing.expectEqualSlices(i64, &.{ 7, 6, 5, 4, 3, 2, 1 }, seen.items); } test "each filter narrows the result on its own" { 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(); var blocked_row = plainRow(200, "ads.example.net"); blocked_row.client_ip = "192.0.2.20"; blocked_row.blocked = true; 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, plainRow(300, "two.example.com"), }); var buf: [8]i64 = undefined; const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" }); try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf)); const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" }); try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf)); // An exact match, not a prefix: the seeded clients share the first octets. const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" }); try testing.expectEqual(@as(usize, 0), no_client.items.len); const only_blocked = try selectQueries(&database, arena, .{ .blocked = true }); try testing.expectEqualSlices(i64, &.{2}, ids(only_blocked.items, &buf)); const only_allowed = try selectQueries(&database, arena, .{ .blocked = false }); try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(only_allowed.items, &buf)); // Every filter at once, all satisfied by the one blocked row. const combined = try selectQueries(&database, arena, .{ .limit = 10, .before = 3, .domain_substring = "ads", .client = "192.0.2.20", .blocked = true, .since = 200, .until = 300, }); try testing.expectEqualSlices(i64, &.{2}, ids(combined.items, &buf)); } test "since is inclusive, until is exclusive, and an empty range selects nothing" { 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(100, "a.example"), plainRow(200, "b.example"), plainRow(300, "c.example"), }); var buf: [8]i64 = undefined; const window = try selectQueries(&database, arena, .{ .since = 100, .until = 300 }); try testing.expectEqualSlices(i64, &.{ 2, 1 }, ids(window.items, &buf)); const after = try selectQueries(&database, arena, .{ .since = 300 }); try testing.expectEqualSlices(i64, &.{3}, ids(after.items, &buf)); const empty = try selectQueries(&database, arena, .{ .since = 300, .until = 300 }); try testing.expectEqual(@as(usize, 0), empty.items.len); const beyond = try selectQueries(&database, arena, .{ .since = 1000 }); try testing.expectEqual(@as(usize, 0), beyond.items.len); } test "a domain substring matches % and _ as literal characters" { 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_b.example"), plainRow(20, "axb.example"), plainRow(30, "a%b.example"), plainRow(40, "azzb.example"), plainRow(50, "back\\slash.example"), }); var buf: [8]i64 = undefined; // Unescaped, `_` is LIKE's single-character wildcard and would also match // "axb"; escaped, it matches only the underscore. const underscore = try selectQueries(&database, arena, .{ .domain_substring = "a_b" }); try testing.expectEqualSlices(i64, &.{1}, ids(underscore.items, &buf)); // Unescaped, `%` would match everything from "a" to "b", so "azzb" too. const percent = try selectQueries(&database, arena, .{ .domain_substring = "a%b" }); try testing.expectEqualSlices(i64, &.{3}, ids(percent.items, &buf)); // The escape character escapes itself, so it is searchable as well. const backslash = try selectQueries(&database, arena, .{ .domain_substring = "k\\s" }); try testing.expectEqualSlices(i64, &.{5}, ids(backslash.items, &buf)); // An empty needle is `%%`, which matches every row rather than none. const all = try selectQueries(&database, arena, .{ .domain_substring = "" }); try testing.expectEqual(@as(usize, 5), all.items.len); } test "the built SQL never carries a filter value and fits its buffer" { var sql: Sql = .{}; sql.put(select_head); sql.predicate(where_before); sql.predicate(where_domain); sql.predicate(where_client); sql.predicate(where_blocked); sql.predicate(where_since); sql.predicate(where_until); sql.put(select_tail); // Every predicate present is the longest reachable statement. try testing.expect(sql.len <= Sql.capacity); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE")); try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND")); // Six filters plus the LIMIT, each a bare parameter. try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?")); } test "likePattern wraps the needle and neutralises every metacharacter" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqualStrings("%plain%", try likePattern(arena, "plain")); try testing.expectEqualStrings("%a\\_b%", try likePattern(arena, "a_b")); try testing.expectEqualStrings("%a\\%b%", try likePattern(arena, "a%b")); try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b")); try testing.expectEqualStrings("%%", try likePattern(arena, "")); } // --------------------------------------------------------------------------- // period aggregations (milestone 30) // --------------------------------------------------------------------------- const agg_since: i64 = 1_700_000_000; const agg_width: u32 = 60; const agg_buckets: u32 = 10; /// One row of the aggregation fixtures. Everything the three breakdowns read /// is a parameter; everything else is the same on every row, so a test that /// changes an outcome names the reason it changed. fn aggRow(offset: i64, client: []const u8, qtype: ?u16, kind: provenance.RouteKind, source: ?[]const u8) Row { return .{ .timestamp = agg_since + offset, .domain = "example.com", .client_ip = client, .qtype = qtype, .qclass = 1, .rcode = 0, .blocked = kind == .blocked, .response_time_us = 1000, .cache_hit = kind == .cache, .upstream = if (kind == .upstream) source else null, .group_id = 1, .group_name = "default", .policy_action = if (kind == .blocked) .block else .allow, .policy_reason = if (kind == .blocked) .blocklist_domain else .no_match, .matched = null, .source_id = null, // Blocklist provenance, deliberately set on every row: the routes // breakdown must never group by it. .source_name = "StevenBlack", .cname_target = null, .safe_search_target = null, .route_kind = kind, .forward_zone = if (kind == .forward_zone) source else null, }; } test "the type breakdown groups by qtype, keeps the null row and orders it last" { 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, &.{ aggRow(0, "192.0.2.10", 1, .upstream, "9.9.9.9"), aggRow(1, "192.0.2.10", 1, .upstream, "9.9.9.9"), aggRow(2, "192.0.2.10", 1, .upstream, "9.9.9.9"), aggRow(3, "192.0.2.10", 28, .upstream, "9.9.9.9"), aggRow(4, "192.0.2.10", 28, .upstream, "9.9.9.9"), // Ties with qtype 28, so the tie-break puts the lower code first. aggRow(5, "192.0.2.10", 16, .upstream, "9.9.9.9"), aggRow(6, "192.0.2.10", 16, .upstream, "9.9.9.9"), // A row with no type at all: its own group, never a dropped row. aggRow(7, "192.0.2.10", null, .upstream, "9.9.9.9"), aggRow(8, "192.0.2.10", null, .upstream, "9.9.9.9"), // Outside the window. aggRow(-1, "192.0.2.10", 255, .upstream, "9.9.9.9"), }); const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).types; try testing.expectEqual(@as(usize, 4), rows.len); try testing.expectEqual(@as(?u16, 1), rows[0].qtype); try testing.expectEqual(@as(u64, 3), rows[0].count); try testing.expectEqual(@as(?u16, 16), rows[1].qtype); try testing.expectEqual(@as(?u16, 28), rows[2].qtype); try testing.expectEqual(@as(?u16, null), rows[3].qtype); try testing.expectEqual(@as(u64, 2), rows[3].count); } test "an empty window has no type rows at all" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).types; try testing.expectEqual(@as(usize, 0), rows.len); } test "the route breakdown keys on the answering resolver, not on blocklist provenance" { 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, &.{ aggRow(0, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"), aggRow(1, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"), aggRow(2, "192.0.2.10", 1, .upstream, "https://b.example/dns-query"), // An upstream row whose resolver the log did not record: its own group. aggRow(3, "192.0.2.10", 1, .upstream, null), aggRow(4, "192.0.2.10", 1, .forward_zone, "lan"), aggRow(5, "192.0.2.10", 1, .blocked, null), aggRow(6, "192.0.2.10", 1, .cache, null), aggRow(7, "192.0.2.10", 1, .local, null), aggRow(8, "192.0.2.10", 1, .rejected, null), }); const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).routes; // Two upstreams, one null-source upstream, one forward zone and four // source-less kinds. Every row carries the same `source_name`, so a // breakdown that grouped by it would collapse to one row. try testing.expectEqual(@as(usize, 8), rows.len); try testing.expectEqual(provenance.RouteKind.upstream, rows[0].route); try testing.expectEqualStrings("https://a.example/dns-query", rows[0].source.?); try testing.expectEqual(@as(u64, 2), rows[0].count); // The seven remaining rows all count 1, so the tie-break orders them: // route ascending, then source ascending with nulls last. for (rows[1..]) |row| try testing.expectEqual(@as(u64, 1), row.count); try testing.expectEqual(provenance.RouteKind.blocked, rows[1].route); try testing.expectEqual(@as(?[]const u8, null), rows[1].source); try testing.expectEqual(provenance.RouteKind.cache, rows[2].route); try testing.expectEqual(provenance.RouteKind.forward_zone, rows[3].route); try testing.expectEqualStrings("lan", rows[3].source.?); try testing.expectEqual(provenance.RouteKind.local, rows[4].route); try testing.expectEqual(provenance.RouteKind.rejected, rows[5].route); try testing.expectEqual(provenance.RouteKind.upstream, rows[6].route); try testing.expectEqualStrings("https://b.example/dns-query", rows[6].source.?); try testing.expectEqual(provenance.RouteKind.upstream, rows[7].route); try testing.expectEqual(@as(?[]const u8, null), rows[7].source); } test "an empty window has no route rows at all" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).routes; try testing.expectEqual(@as(usize, 0), rows.len); } test "an empty window still has a zero-filled other series and no named clients" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients; try testing.expectEqual(@as(usize, 0), result.clients.len); try testing.expectEqual(@as(usize, agg_buckets), result.other.len); for (result.other) |count| try testing.expectEqual(@as(u64, 0), count); } test "client series are bucket-aligned, zero-filled and ranked by in-window total" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try seed(&database, &.{ aggRow(0, "192.0.2.20", 1, .upstream, "9.9.9.9"), aggRow(1, "192.0.2.20", 1, .upstream, "9.9.9.9"), aggRow(agg_width * 3, "192.0.2.20", 1, .upstream, "9.9.9.9"), aggRow(agg_width * 3, "192.0.2.10", 1, .upstream, "9.9.9.9"), // Outside the window on both sides. aggRow(-1, "192.0.2.20", 1, .upstream, "9.9.9.9"), aggRow(agg_width * agg_buckets, "192.0.2.10", 1, .upstream, "9.9.9.9"), }); const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients; try testing.expectEqual(@as(usize, 2), result.clients.len); try testing.expectEqualStrings("192.0.2.20", result.clients[0].client); try testing.expectEqualStrings("192.0.2.10", result.clients[1].client); for (result.clients) |series| try testing.expectEqual(@as(usize, agg_buckets), series.buckets.len); try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]); try testing.expectEqual(@as(u64, 1), result.clients[0].buckets[3]); try testing.expectEqual(@as(u64, 0), result.clients[0].buckets[9]); try testing.expectEqual(@as(u64, 1), result.clients[1].buckets[3]); for (result.other) |count| try testing.expectEqual(@as(u64, 0), count); } test "the ninth client folds into other and the cut is the same on every read" { 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(); // Nine clients, each with one more query than the next, so the ranking is // total and the ninth is unambiguously the one that folds. var address: [16]u8 = undefined; var index: u32 = 0; while (index < 9) : (index += 1) { const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index}); var repeat: u32 = 0; while (repeat <= index) : (repeat += 1) { try seed(&database, &.{aggRow(@intCast(repeat), client, 1, .upstream, "9.9.9.9")}); } } const result = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).clients; try testing.expectEqual(@as(usize, max_client_series), result.clients.len); // The busiest is 192.0.2.108 with nine rows; the lone folded client is // 192.0.2.100 with one. try testing.expectEqualStrings("192.0.2.108", result.clients[0].client); for (result.clients) |series| { try testing.expect(!std.mem.eql(u8, "192.0.2.100", series.client)); } var other_total: u64 = 0; for (result.other) |count| other_total += count; try testing.expectEqual(@as(u64, 1), other_total); } test "the three breakdowns conserve the window's total" { 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(); // A matrix that exercises every branch the aggregations partition on: null // and non-null qtypes, every route kind, a null resolver identity, and ten // clients so the top-eight cut has a residual to carry. const kinds = [_]provenance.RouteKind{ .blocked, .local, .forward_zone, .upstream, .cache, .rejected }; var address: [16]u8 = undefined; var index: u32 = 0; while (index < 40) : (index += 1) { const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index % 10}); const kind = kinds[index % kinds.len]; try seed(&database, &.{aggRow( @intCast(index % (agg_width * agg_buckets)), client, if (index % 7 == 0) null else @intCast(1 + index % 3), kind, if (index % 11 == 0) null else "9.9.9.9", )}); } const result = try overview(&database, arena, agg_since, agg_width, agg_buckets); try testing.expect(result.totals.queries > 0); var typed: u64 = 0; for (result.types) |row| typed += row.count; try testing.expectEqual(result.totals.queries, typed); var routed: u64 = 0; for (result.routes) |row| routed += row.count; try testing.expectEqual(result.totals.queries, routed); // Per bucket, not just in total: a series misaligned by one bucket would // still sum correctly over the window. for (result.buckets, 0..) |bucket, at| { var summed: u64 = result.clients.other[at]; for (result.clients.clients) |series| summed += series.buckets[at]; try testing.expectEqual(bucket.queries, summed); } } test "the aggregations pass a redacted client through as the log stored it" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); // `hide_client_ips` is applied by the logger before the row is written, so // the read path has nothing to transform — and must not invent anything // either. The marker is the client's whole identity here. try seed(&database, &.{ aggRow(0, logger.hidden_marker, 1, .upstream, "9.9.9.9"), aggRow(1, logger.hidden_marker, 1, .upstream, "9.9.9.9"), }); const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients; try testing.expectEqual(@as(usize, 1), result.clients.len); try testing.expectEqualStrings(logger.hidden_marker, result.clients[0].client); try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]); } test "a prune committed mid-read is invisible to the reader's transaction" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/querylog.db", .{ tmp_prefix, &tmp.sub_path }); var reader = try db.Db.open(path, .{ .mode = .read_write_create }); defer reader.close(); try db.applyPragmas(&reader, .{}); try reader.exec(querylog_schema.ddl); // The seeded watermark is the file's creation second, which is now; the // fixtures below are in 2023, so the prune's cutoff would never advance it. try reader.exec( \\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1 ); try seed(&reader, &.{ plainRow(agg_since, "a.example"), plainRow(agg_since + 1, "b.example"), plainRow(agg_since + 300, "c.example"), }); // A second connection to the same file, as retention has in production. var pruner = try db.Db.open(path, .{ .mode = .read_write_create }); defer pruner.close(); try db.applyPragmas(&pruner, .{}); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var tx = try db.ReadTx.begin(&reader); const before = (try overview(&reader, arena, agg_since, 1000, 1)).totals; const watermark_before = try availableSince(&reader); try testing.expectEqual(@as(u64, 3), before.queries); // The prune commits while the reader's transaction is open. const pruned = try pruneOlderThan(&pruner, agg_since + 200); try testing.expectEqual(@as(i64, 2), pruned.deleted); try testing.expect(pruned.available_since > watermark_before); // Neither half of the answer moved: the rows the reader would report and // the watermark it would tag them with still describe one state. const during = (try overview(&reader, arena, agg_since, 1000, 1)).totals; try testing.expectEqual(before.queries, during.queries); try testing.expectEqual(watermark_before, try availableSince(&reader)); try tx.commit(); // The next response sees the prune — both halves of it. const after = (try overview(&reader, arena, agg_since, 1000, 1)).totals; try testing.expectEqual(@as(u64, 1), after.queries); try testing.expectEqual(pruned.available_since, try availableSince(&reader)); } // --------------------------------------------------------------------------- // the projections and the Overview read path (milestone 36) // --------------------------------------------------------------------------- /// Floors a `query_log.timestamp` onto the projection grid in SQL. SQLite's `/` /// truncates toward zero, so a negative timestamp needs the bias — the same /// reason `bucketOf` is `@divFloor`. const bucket_expr = "(CASE WHEN timestamp >= 0 THEN timestamp / 1800 * 1800" ++ " ELSE (timestamp - 1799) / 1800 * 1800 END)"; /// Each projection table beside a from-scratch recomputation of it. Both sides /// have unique keys, so `EXCEPT` in both directions is an exact equality test /// rather than a containment one. const recompute_checks = [_]struct { projection: []const u8, recompute: []const u8 }{ .{ .projection = "SELECT bucket, queries, blocked, cached, rt_sum, rt_count FROM bucket_totals", .recompute = "SELECT " ++ bucket_expr ++ ", count(*), coalesce(sum(blocked <> 0), 0)," ++ " coalesce(sum(cache_hit = 1), 0), coalesce(sum(response_time_us), 0)," ++ " count(response_time_us) FROM query_log GROUP BY 1", }, .{ .projection = "SELECT bucket, client_ip, queries FROM bucket_clients", .recompute = "SELECT " ++ bucket_expr ++ ", client_ip, count(*) FROM query_log GROUP BY 1, 2", }, .{ .projection = "SELECT bucket, qtype, count FROM bucket_types", .recompute = "SELECT " ++ bucket_expr ++ ", coalesce(qtype, -1), count(*) FROM query_log GROUP BY 1, 2", }, .{ .projection = "SELECT bucket, route_kind, source_present, source_text, count FROM bucket_routes", .recompute = "SELECT " ++ bucket_expr ++ ", route_kind, source IS NOT NULL, coalesce(source, ''), count(*)" ++ " FROM (SELECT timestamp, route_kind, CASE route_kind WHEN 'upstream' THEN upstream" ++ " WHEN 'forward_zone' THEN forward_zone END AS source FROM query_log) GROUP BY 1, 2, 3, 4", }, }; /// Exported for `querylog_migrations.zig`'s fixture and migration tests: the /// authority on projection coherence is this file, and a second copy of the /// recompute SQL there would be free to drift from the writer it checks. pub fn expectProjectionsMatchRecompute(database: *db.Db) !void { for (recompute_checks) |check| { var buf: [4096]u8 = undefined; const sql = try std.fmt.bufPrint( &buf, "SELECT (SELECT count(*) FROM ({s} EXCEPT {s})) + (SELECT count(*) FROM ({s} EXCEPT {s}))", .{ check.projection, check.recompute, check.recompute, check.projection }, ); try testing.expectEqual(@as(i64, 0), try database.queryInt(sql)); } } test "bucketOf floors onto the grid on both sides of the epoch" { try testing.expectEqual(@as(i64, 0), bucketOf(0)); try testing.expectEqual(@as(i64, 0), bucketOf(1799)); try testing.expectEqual(@as(i64, grain), bucketOf(grain)); try testing.expectEqual(@as(i64, grain), bucketOf(grain + 1)); // Truncation toward zero would name bucket 0 for all three of these, which // is the bucket *after* the one the row belongs to. try testing.expectEqual(@as(i64, -grain), bucketOf(-1)); try testing.expectEqual(@as(i64, -grain), bucketOf(-grain)); try testing.expectEqual(@as(i64, -2 * grain), bucketOf(-grain - 1)); } test "a batch maintains all four projections in the transaction that writes the rows" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); var blocked_row = aggRow(0, "192.0.2.20", null, .blocked, null); blocked_row.response_time_us = null; try writer.writeBatch(&.{ aggRow(0, "192.0.2.10", 1, .upstream, "9.9.9.9"), aggRow(5, "192.0.2.10", 1, .cache, null), blocked_row, // The next grid bucket, so the floor is what separates them. aggRow(grain, "192.0.2.10", 28, .upstream, null), }); // Two grid buckets: three rows floored into the first, one into the second. try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM bucket_totals")); try testing.expectEqual(bucketOf(agg_since), try database.queryInt("SELECT min(bucket) FROM bucket_totals")); try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT queries FROM bucket_totals ORDER BY bucket LIMIT 1")); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT blocked FROM bucket_totals ORDER BY bucket LIMIT 1")); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT cached FROM bucket_totals ORDER BY bucket LIMIT 1")); // The blocked row carries no response time, so two of the three are timed. try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT rt_count FROM bucket_totals ORDER BY bucket LIMIT 1")); try expectProjectionsMatchRecompute(&database); // A second batch accumulates onto the same keys rather than replacing them. try writer.writeBatch(&.{aggRow(7, "192.0.2.10", 1, .upstream, "9.9.9.9")}); try testing.expectEqual(@as(i64, 4), try database.queryInt("SELECT queries FROM bucket_totals ORDER BY bucket LIMIT 1")); try testing.expectEqual( @as(i64, 3), try database.queryInt("SELECT queries FROM bucket_clients ORDER BY bucket, client_ip LIMIT 1"), ); try expectProjectionsMatchRecompute(&database); } test "a NULL qtype and a NULL route source are stored losslessly" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{ aggRow(0, "192.0.2.10", null, .upstream, null), aggRow(1, "192.0.2.10", 1, .upstream, ""), }); try testing.expectEqual(@as(i64, null_qtype), try database.queryInt("SELECT min(qtype) FROM bucket_types")); // A row whose resolver the log did not record, and a row whose resolver is // the empty string, are different keys — which is what `source_present` // buys over "empty means absent". try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM bucket_routes")); try testing.expectEqual( @as(i64, 1), try database.queryInt("SELECT count(*) FROM bucket_routes WHERE source_present = 0 AND source_text = ''"), ); try testing.expectEqual( @as(i64, 1), try database.queryInt("SELECT count(*) FROM bucket_routes WHERE source_present = 1 AND source_text = ''"), ); try expectProjectionsMatchRecompute(&database); } test "a failed projection update rolls the whole batch back and the writer survives it" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); try writer.writeBatch(&.{aggRow(0, "192.0.2.10", 1, .upstream, "9.9.9.9")}); try database.exec( \\CREATE TRIGGER refuse_projection BEFORE INSERT ON bucket_clients \\WHEN new.client_ip = 'boom' \\BEGIN SELECT RAISE(ABORT, 'refused'); END; ); try testing.expectError(error.Constraint, writer.writeBatch(&.{ aggRow(1, "boom", 1, .upstream, "9.9.9.9"), })); // The raw row went in before the projection statement failed; neither // survived, because they were one transaction. try testing.expectEqual(@as(i64, 1), try countRows(&database)); try expectProjectionsMatchRecompute(&database); try database.exec("DROP TRIGGER refuse_projection;"); try writer.writeBatch(&.{aggRow(2, "192.0.2.11", 1, .upstream, "9.9.9.9")}); try testing.expectEqual(@as(i64, 2), try countRows(&database)); try expectProjectionsMatchRecompute(&database); } test "a prune on the grid drops whole projection buckets and leaves the rest" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); const base = bucketOf(agg_since); try seedAt(&database, &.{ base, base + grain, base + 2 * grain }); _ = try pruneOlderThan(&database, base + 2 * grain); try testing.expectEqual(@as(i64, 1), try countRows(&database)); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM bucket_totals")); try testing.expectEqual(base + 2 * grain, try database.queryInt("SELECT bucket FROM bucket_totals")); try expectProjectionsMatchRecompute(&database); } test "a prune off the grid recomputes the straddling bucket instead of estimating it" { var database = try openLog(); defer database.close(); const base = bucketOf(agg_since); // Four rows in one bucket; the cutoff falls between the second and third. try seedAt(&database, &.{ base, base + 100, base + 500, base + 900 }); _ = try pruneOlderThan(&database, base + 400); try testing.expectEqual(@as(i64, 2), try countRows(&database)); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM bucket_totals")); try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT queries FROM bucket_totals")); try expectProjectionsMatchRecompute(&database); // Emptying the straddling bucket removes its row rather than writing zeros. _ = try pruneOlderThan(&database, base + grain - 1); try testing.expectEqual(@as(i64, 0), try countRows(&database)); try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM bucket_totals")); try expectProjectionsMatchRecompute(&database); } test "a failed projection prune rolls back the raw delete and the watermark with it" { var database = try openLog(); defer database.close(); const base = bucketOf(agg_since); try seedAt(&database, &.{ base, base + grain }); const watermark = try availableSince(&database); try database.exec( \\CREATE TRIGGER refuse_bucket_prune BEFORE DELETE ON bucket_clients \\BEGIN SELECT RAISE(ABORT, 'refused'); END; ); try testing.expectError(error.Constraint, pruneOlderThan(&database, base + grain)); try testing.expectEqual(@as(i64, 2), try countRows(&database)); try testing.expectEqual(watermark, try availableSince(&database)); try expectProjectionsMatchRecompute(&database); try database.exec("DROP TRIGGER refuse_bucket_prune;"); _ = try pruneOlderThan(&database, base + grain); try testing.expectEqual(@as(i64, 1), try countRows(&database)); try expectProjectionsMatchRecompute(&database); } test "a failed straddling-bucket replacement rolls back the raw delete and the watermark with it" { var database = try openLog(); defer database.close(); const base = bucketOf(agg_since); // One whole bucket to delete outright, and one the cutoff cuts in half. try seedAt(&database, &.{ base, base + grain, base + grain + 100, base + grain + 900 }); const watermark = try availableSince(&database); const straddling_totals = "SELECT queries FROM bucket_totals ORDER BY bucket DESC LIMIT 1"; const straddling_queries = try database.queryInt(straddling_totals); // Only the recompute inserts into a projection table; the bulk prune above // it deletes. So this trigger fires during the straddling-bucket // replacement and nowhere else in the pass. try database.exec( \\CREATE TRIGGER refuse_recompute BEFORE INSERT ON bucket_totals \\BEGIN SELECT RAISE(ABORT, 'refused'); END; ); try testing.expectError(error.Constraint, pruneOlderThan(&database, base + grain + 500)); // The raw delete, the watermark advance, the whole-bucket projection // deletes and the straddling bucket's own delete all went back together. try testing.expectEqual(@as(i64, 4), try countRows(&database)); try testing.expectEqual(watermark, try availableSince(&database)); try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM bucket_totals")); try testing.expectEqual(straddling_queries, try database.queryInt(straddling_totals)); try expectProjectionsMatchRecompute(&database); try database.exec("DROP TRIGGER refuse_recompute;"); _ = try pruneOlderThan(&database, base + grain + 500); try testing.expectEqual(@as(i64, 1), try countRows(&database)); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT queries FROM bucket_totals")); try expectProjectionsMatchRecompute(&database); } /// One row per timestamp, all in the same batch, with the fixture defaults. fn seedAt(database: *db.Db, timestamps: []const i64) !void { var writer = try BatchWriter.init(testing.allocator, database); defer writer.deinit(); var rows: [16]Row = undefined; for (rows[0..timestamps.len], timestamps) |*row, ts| { row.* = plainRow(ts, "example.com"); } try writer.writeBatch(rows[0..timestamps.len]); } test "an arbitrary interleaving of batches and prunes leaves every projection exact" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); var prng: std.Random.DefaultPrng = .init(0x36a5_0000_36a5); const random = prng.random(); const kinds = [_]provenance.RouteKind{ .blocked, .local, .forward_zone, .upstream, .cache, .rejected }; const sources = [_]?[]const u8{ "9.9.9.9", "https://a.example/dns-query", null, "" }; const base = bucketOf(agg_since); var rows: [24]Row = undefined; var addresses: [24][20]u8 = undefined; var step: usize = 0; while (step < 60) : (step += 1) { if (step % 5 == 4) { // Half the cutoffs land off the grid on purpose: the straddling // bucket is the only case a delete alone cannot get right. const offset = random.intRangeAtMost(i64, 0, 6 * grain); const skew: i64 = if (random.boolean()) 0 else random.intRangeLessThan(i64, 1, grain); _ = try pruneOlderThan(&database, base + offset + skew); } else { const count = random.intRangeAtMost(usize, 1, rows.len); for (rows[0..count], addresses[0..count]) |*row, *address| { const client = try std.fmt.bufPrint(address, "192.0.2.{d}", .{random.intRangeAtMost(u8, 1, 5)}); const kind = kinds[random.uintLessThan(usize, kinds.len)]; row.* = plainRow(base + random.intRangeAtMost(i64, 0, 8 * grain), "example.com"); row.client_ip = client; row.qtype = if (random.boolean()) null else random.intRangeAtMost(u16, 1, 3); row.blocked = kind == .blocked; row.cache_hit = if (random.boolean()) null else kind == .cache; row.response_time_us = if (random.boolean()) null else random.intRangeAtMost(i64, 0, 5000); row.route_kind = kind; const source = sources[random.uintLessThan(usize, sources.len)]; row.upstream = if (kind == .upstream) source else null; row.forward_zone = if (kind == .forward_zone) source else null; row.policy_action = if (kind == .blocked) .block else .allow; row.policy_reason = if (kind == .blocked) .blocklist_domain else .no_match; } try writer.writeBatch(rows[0..count]); } try expectProjectionsMatchRecompute(&database); } // The walk has to have exercised both halves of it. try testing.expect((try countRows(&database)) > 0); } // --- the Overview equivalence oracle -------------------------------------- /// A test-only copy of the five SQL aggregates `overview` replaces, kept here /// so it survives their deletion. It is the independent statement of the /// contract: if `overview` and this disagree on any window, one of them is /// wrong, and this one is the one the goldens were written against. const oracle = struct { const totals_sql = \\SELECT count(*), \\ coalesce(sum(blocked <> 0), 0), \\ count(DISTINCT client_ip), \\ coalesce(sum(response_time_us), 0), \\ count(response_time_us) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 ; const buckets_sql = \\SELECT (timestamp - ?1) / ?2, \\ count(*), \\ coalesce(sum(blocked <> 0), 0), \\ coalesce(sum(cache_hit = 1), 0) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?3 \\ GROUP BY 1 ; const types_sql = \\SELECT qtype, count(*) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 \\ GROUP BY qtype \\ ORDER BY count(*) DESC, qtype IS NULL, qtype ASC ; const routes_sql = \\SELECT route_kind, \\ CASE route_kind \\ WHEN 'upstream' THEN upstream \\ WHEN 'forward_zone' THEN forward_zone \\ END AS source, \\ count(*) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 \\ GROUP BY route_kind, source \\ ORDER BY count(*) DESC, route_kind ASC, source IS NULL, source ASC ; const clients_rank_sql = \\SELECT client_ip \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?2 \\ GROUP BY client_ip \\ ORDER BY count(*) DESC, client_ip ASC \\ LIMIT ?3 ; const clients_buckets_sql = \\SELECT client_ip, (timestamp - ?1) / ?2, count(*) \\ FROM query_log \\ WHERE timestamp >= ?1 AND timestamp < ?3 \\ GROUP BY 1, 2 ; fn build( database: *db.Db, arena: Allocator, since: i64, bucket_seconds: u32, bucket_count: u32, ) !Overview { const width: i64 = bucket_seconds; const until = since + width * bucket_count; var totals: StatsTotals = undefined; { var stmt = try database.prepare(totals_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); try testing.expect(try stmt.step()); const timed = stmt.columnInt(4); totals = .{ .queries = try countOf(stmt.columnInt(0)), .blocked = try countOf(stmt.columnInt(1)), .distinct_clients = try countOf(stmt.columnInt(2)), .avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(3), timed), }; } const buckets = try arena.alloc(Bucket, bucket_count); for (buckets, 0..) |*bucket, i| bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0, }; { var stmt = try database.prepare(buckets_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, width); try stmt.bindInt(3, until); while (try stmt.step()) { const index: usize = @intCast(stmt.columnInt(0)); buckets[index].queries = try countOf(stmt.columnInt(1)); buckets[index].blocked = try countOf(stmt.columnInt(2)); buckets[index].cached = try countOf(stmt.columnInt(3)); } } var types: std.ArrayList(TypeCount) = .empty; { var stmt = try database.prepare(types_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { try types.append(arena, .{ .qtype = if (stmt.isNull(0)) null else try columnU16(&stmt, 0), .count = try countOf(stmt.columnInt(1)), }); } } var routes: std.ArrayList(RouteCount) = .empty; { var stmt = try database.prepare(routes_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); while (try stmt.step()) { try routes.append(arena, .{ .route = try provenance.parse(provenance.RouteKind, stmt.columnText(0)), .source = try stmt.columnTextAllocOrNull(arena, 1), .count = try countOf(stmt.columnInt(2)), }); } } var names: std.ArrayList([]const u8) = .empty; var series: std.ArrayList([]u64) = .empty; { var stmt = try database.prepare(clients_rank_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, until); try stmt.bindInt(3, max_client_series); while (try stmt.step()) { try names.append(arena, try stmt.columnTextAlloc(arena, 0)); try series.append(arena, try zeroedBuckets(arena, bucket_count)); } } const other = try zeroedBuckets(arena, bucket_count); { var stmt = try database.prepare(clients_buckets_sql); defer stmt.deinit(); try stmt.bindInt(1, since); try stmt.bindInt(2, width); try stmt.bindInt(3, until); while (try stmt.step()) { const index: usize = @intCast(stmt.columnInt(1)); const count = try countOf(stmt.columnInt(2)); const client = stmt.columnText(0); const target = for (names.items, series.items) |name, target_buckets| { if (std.mem.eql(u8, name, client)) break target_buckets; } else other; target[index] += count; } } const clients = try arena.alloc(ClientSeries, names.items.len); for (clients, names.items, series.items) |*entry, name, entry_buckets| { entry.* = .{ .client = name, .buckets = entry_buckets }; } return .{ .totals = totals, .buckets = buckets, .clients = .{ .clients = clients, .other = other }, .types = types.items, .routes = routes.items, }; } }; fn expectOverviewEqual(expected: Overview, actual: Overview) !void { try testing.expectEqual(expected.totals.queries, actual.totals.queries); try testing.expectEqual(expected.totals.blocked, actual.totals.blocked); try testing.expectEqual(expected.totals.distinct_clients, actual.totals.distinct_clients); try testing.expectEqual(expected.totals.avg_response_time_us, actual.totals.avg_response_time_us); try testing.expectEqualSlices(Bucket, expected.buckets, actual.buckets); try testing.expectEqual(expected.types.len, actual.types.len); for (expected.types, actual.types) |want, got| { try testing.expectEqual(want.qtype, got.qtype); try testing.expectEqual(want.count, got.count); } try testing.expectEqual(expected.routes.len, actual.routes.len); for (expected.routes, actual.routes) |want, got| { try testing.expectEqual(want.route, got.route); try testing.expectEqual(want.count, got.count); if (want.source) |source| { try testing.expectEqualStrings(source, got.source orelse return error.TestExpectedEqual); } else { try testing.expectEqual(@as(?[]const u8, null), got.source); } } try testing.expectEqual(expected.clients.clients.len, actual.clients.clients.len); for (expected.clients.clients, actual.clients.clients) |want, got| { try testing.expectEqualStrings(want.client, got.client); try testing.expectEqualSlices(u64, want.buckets, got.buckets); } try testing.expectEqualSlices(u64, expected.clients.other, actual.clients.other); } /// Every window the equivalence test walks. The first is the raw path (60 s /// buckets, the 1h period); the rest are the projection path at each serving /// width the period grammar offers. const overview_windows = [_]struct { bucket_seconds: u32, bucket_count: u32 }{ .{ .bucket_seconds = 60, .bucket_count = 60 }, .{ .bucket_seconds = 1800, .bucket_count = 48 }, .{ .bucket_seconds = 3600, .bucket_count = 168 }, .{ .bucket_seconds = 21600, .bucket_count = 120 }, }; fn expectOverviewMatchesOracle(database: *db.Db, since: i64) !void { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); for (overview_windows) |window| { const want = try oracle.build(database, arena, since, window.bucket_seconds, window.bucket_count); const got = try overview(database, arena, since, window.bucket_seconds, window.bucket_count); expectOverviewEqual(want, got) catch |err| { std.debug.print("overview mismatch at bucket_seconds={d}\n", .{window.bucket_seconds}); return err; }; } } test "overview equals the oracle on an empty database" { var database = try openLog(); defer database.close(); try expectOverviewMatchesOracle(&database, bucketOf(agg_since)); } test "overview equals the oracle over a window with no rows in it" { var database = try openLog(); defer database.close(); try seedAt(&database, &.{ agg_since, agg_since + 10 }); // Two full 30-day spans later: every panel is empty and the axis is still // whole. try expectOverviewMatchesOracle(&database, bucketOf(agg_since) + 240 * grain); } test "overview equals the oracle across nulls, ties and a bucket still in progress" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); const since = bucketOf(agg_since); const kinds = [_]provenance.RouteKind{ .blocked, .local, .forward_zone, .upstream, .cache, .rejected }; // Ten clients so the top-eight cut has a residual, and the counts are // deliberately equal in pairs so the `client_ip` tie-break decides. var rows: [120]Row = undefined; var addresses: [120][20]u8 = undefined; for (&rows, &addresses, 0..) |*row, *address, i| { const client = try std.fmt.bufPrint(address, "192.0.2.{d}", .{100 + (i / 2) % 10}); const kind = kinds[i % kinds.len]; row.* = plainRow(since + @as(i64, @intCast(i)) * 137, "example.com"); row.client_ip = client; // Every seventh row carries no query type, and the types collide in // count so the null-last tie-break is exercised. row.qtype = if (i % 7 == 0) null else @intCast(1 + i % 3); row.blocked = kind == .blocked; row.cache_hit = if (i % 5 == 0) null else kind == .cache; row.response_time_us = if (i % 4 == 0) null else @intCast(100 + i); row.route_kind = kind; // Every eleventh upstream row records no resolver: the NULL-source // group the routes breakdown must keep rather than drop. const source: ?[]const u8 = if (i % 11 == 0) null else if (i % 2 == 0) "9.9.9.9" else "https://a.example/dns-query"; row.upstream = if (kind == .upstream) source else null; row.forward_zone = if (kind == .forward_zone) source else null; row.policy_action = if (kind == .blocked) .block else .allow; row.policy_reason = if (kind == .blocked) .blocklist_domain else .no_match; } try writer.writeBatch(&rows); try expectOverviewMatchesOracle(&database, since); // A window whose last bucket is only partly filled: `since` moved so the // newest rows land inside the final bucket rather than closing it. try expectOverviewMatchesOracle(&database, since - 40 * grain); // And after a prune off the grid, so the projections the read path uses are // the recomputed ones. _ = try pruneOlderThan(&database, since + 900); try expectProjectionsMatchRecompute(&database); try expectOverviewMatchesOracle(&database, since); } test "overview equals the oracle after an interleaving of batches and prunes" { var database = try openLog(); defer database.close(); var writer = try BatchWriter.init(testing.allocator, &database); defer writer.deinit(); const since = bucketOf(agg_since); var prng: std.Random.DefaultPrng = .init(0x0e17_3a99); const random = prng.random(); var rows: [16]Row = undefined; var addresses: [16][20]u8 = undefined; var step: usize = 0; while (step < 12) : (step += 1) { const count = random.intRangeAtMost(usize, 1, rows.len); for (rows[0..count], addresses[0..count]) |*row, *address| { const client = try std.fmt.bufPrint(address, "10.0.0.{d}", .{random.intRangeAtMost(u8, 1, 12)}); row.* = plainRow(since + random.intRangeAtMost(i64, 0, 20 * grain), "example.com"); row.client_ip = client; row.qtype = if (random.boolean()) null else random.intRangeAtMost(u16, 1, 4); row.response_time_us = if (random.boolean()) null else random.intRangeAtMost(i64, 0, 9000); row.cache_hit = if (random.boolean()) null else random.boolean(); } try writer.writeBatch(rows[0..count]); if (step % 4 == 3) { _ = try pruneOlderThan(&database, since + random.intRangeAtMost(i64, 0, 10 * grain)); } try expectOverviewMatchesOracle(&database, since); } } test "overview refuses a window the projections cannot express" { 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(); const since = bucketOf(agg_since); try testing.expectError(error.Misuse, overview(&database, arena, since, 0, 48)); try testing.expectError(error.Misuse, overview(&database, arena, since, 1800, 0)); try testing.expectError(error.Misuse, overview(&database, arena, std.math.maxInt(i64) - 1, 3600, 48)); // On the projection path the grid is part of the contract: an unaligned // start or a width that is not a whole number of grains cannot be answered // from 30-minute rows, and guessing would be worse than refusing. try testing.expectError(error.Misuse, overview(&database, arena, since + 1, 1800, 48)); try testing.expectError(error.Misuse, overview(&database, arena, since, 2700, 48)); // Below the grain none of that applies: the raw rows carry every second. _ = try overview(&database, arena, since + 1, 60, 60); }