Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
2124 lines
86 KiB
Zig
2124 lines
86 KiB
Zig
//! `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,
|
|
};
|
|
|
|
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)
|
|
;
|
|
|
|
/// 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.
|
|
pub const BatchWriter = struct {
|
|
database: *db.Db,
|
|
insert_domain: db.Stmt,
|
|
select_domain: db.Stmt,
|
|
insert_row: db.Stmt,
|
|
|
|
pub fn init(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();
|
|
const insert_row = try database.prepare(insert_row_sql);
|
|
return .{
|
|
.database = database,
|
|
.insert_domain = insert_domain,
|
|
.select_domain = select_domain,
|
|
.insert_row = insert_row,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *BatchWriter) void {
|
|
self.insert_row.deinit();
|
|
self.select_domain.deinit();
|
|
self.insert_domain.deinit();
|
|
}
|
|
|
|
/// One transaction for the whole batch. 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;
|
|
|
|
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 tx.commit();
|
|
}
|
|
|
|
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.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.
|
|
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!PruneResult {
|
|
var tx = try db.Tx.begin(database);
|
|
errdefer tx.rollback();
|
|
|
|
var deleting = try database.prepare("DELETE FROM query_log WHERE timestamp < ?1");
|
|
defer deleting.deinit();
|
|
try deleting.bindInt(1, cutoff_ts);
|
|
try deleting.exec();
|
|
const deleted = database.changes();
|
|
|
|
var advancing = try database.prepare(
|
|
"UPDATE querylog_meta SET available_since = max(available_since, ?1) WHERE id = 1",
|
|
);
|
|
defer advancing.deinit();
|
|
try advancing.bindInt(1, cutoff_ts);
|
|
try advancing.exec();
|
|
|
|
const watermark = try database.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1");
|
|
try tx.commit();
|
|
return .{ .deleted = deleted, .available_since = watermark };
|
|
}
|
|
|
|
/// The oldest timestamp this file can still answer for. A query window that
|
|
/// starts before it is incomplete, and the API says so rather than charting the
|
|
/// gap as zero.
|
|
pub fn availableSince(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1");
|
|
}
|
|
|
|
/// `PRAGMA wal_checkpoint(TRUNCATE)`: moves the WAL into the database and
|
|
/// 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`, `/api/stats`, `/api/stats/timeseries`)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 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 `/api/stats` rollup for one period. `avg_response_time_us` is `null` when
|
|
/// no row in the window recorded a response time.
|
|
pub const StatsTotals = struct {
|
|
queries: u64,
|
|
blocked: u64,
|
|
distinct_clients: u64,
|
|
avg_response_time_us: ?i64,
|
|
};
|
|
|
|
/// 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.
|
|
const stats_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
|
|
;
|
|
|
|
/// Aggregates `[since, until)`. An empty window is all zeros with a null mean,
|
|
/// not an error.
|
|
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals {
|
|
var stmt = try database.prepare(stats_totals_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, since);
|
|
try stmt.bindInt(2, until);
|
|
|
|
// A bare aggregate always produces exactly one row; no row means the
|
|
// statement is not the one this function prepared.
|
|
if (!try stmt.step()) return error.Misuse;
|
|
|
|
const timed = stmt.columnInt(4);
|
|
return .{
|
|
.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),
|
|
};
|
|
}
|
|
|
|
/// `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 `/api/stats/timeseries`. `ts` is the bucket's inclusive start.
|
|
pub const Bucket = struct {
|
|
ts: i64,
|
|
queries: u64,
|
|
blocked: u64,
|
|
cached: u64,
|
|
};
|
|
|
|
const timeseries_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
|
|
;
|
|
|
|
/// Fills `out` with `out.len` buckets of `bucket_seconds` each, covering
|
|
/// `[since, since + bucket_seconds * out.len)`, and returns how many it wrote.
|
|
///
|
|
/// Every bucket is present: a window with no rows in it is written with zeros
|
|
/// rather than skipped, so the caller charts a contiguous axis without
|
|
/// reconstructing the gaps. Buckets are aligned to `since`, so the caller —
|
|
/// which knows the period grammar of ruling 13 — owns UTC alignment by choosing
|
|
/// `since`.
|
|
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket) db.Error!usize {
|
|
if (out.len == 0) return 0;
|
|
// Both are caller bugs, not runtime conditions: a zero width would make the
|
|
// SQL divide by zero (SQLite yields NULL, silently emptying the chart), and
|
|
// a window that does not fit i64 cannot be asked about.
|
|
if (bucket_seconds == 0) return error.Misuse;
|
|
const width: i64 = bucket_seconds;
|
|
const span = std.math.mul(i64, width, std.math.cast(i64, out.len) orelse
|
|
return error.Misuse) catch return error.Misuse;
|
|
const until = std.math.add(i64, since, span) catch return error.Misuse;
|
|
|
|
for (out, 0..) |*bucket, i| {
|
|
bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0 };
|
|
}
|
|
|
|
var stmt = try database.prepare(timeseries_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, since);
|
|
try stmt.bindInt(2, width);
|
|
try stmt.bindInt(3, until);
|
|
|
|
while (try stmt.step()) {
|
|
// The WHERE clause already bounds the index to `out`; the check is
|
|
// cheap and keeps a schema surprise from writing past the slice.
|
|
const index = std.math.cast(usize, stmt.columnInt(0)) orelse return error.Mismatch;
|
|
if (index >= out.len) return error.Mismatch;
|
|
out[index].queries = try countOf(stmt.columnInt(1));
|
|
out[index].blocked = try countOf(stmt.columnInt(2));
|
|
out[index].cached = try countOf(stmt.columnInt(3));
|
|
}
|
|
return out.len;
|
|
}
|
|
|
|
/// One row of `/api/stats/types`. `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.
|
|
pub const TypeCount = struct {
|
|
qtype: ?u16,
|
|
count: u64,
|
|
};
|
|
|
|
/// `qtype IS NULL` sorts 0 before 1, which puts the null row last within a tie.
|
|
/// The ordering is total, so two reads of one window return the same list in
|
|
/// the same order — which is what makes the goldens byte-stable. It 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.
|
|
const stats_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
|
|
;
|
|
|
|
/// The query-type breakdown of `[since, until)`. No zero rows: a type absent
|
|
/// from the window is absent from the list.
|
|
pub fn statsTypes(
|
|
database: *db.Db,
|
|
arena: Allocator,
|
|
since: i64,
|
|
until: i64,
|
|
) db.Error!std.ArrayList(TypeCount) {
|
|
var out: std.ArrayList(TypeCount) = .empty;
|
|
|
|
var stmt = try database.prepare(stats_types_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, since);
|
|
try stmt.bindInt(2, until);
|
|
|
|
while (try stmt.step()) {
|
|
try out.append(arena, .{
|
|
.qtype = if (stmt.isNull(0)) null else try columnU16(&stmt, 0),
|
|
.count = try countOf(stmt.columnInt(1)),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// One row of `/api/stats/routes`: 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.
|
|
pub const RouteCount = struct {
|
|
route: provenance.RouteKind,
|
|
source: ?[]const u8,
|
|
count: u64,
|
|
};
|
|
|
|
/// A null upstream 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.
|
|
const stats_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
|
|
;
|
|
|
|
/// The answering-route breakdown of `[since, until)`. Strings are copied into
|
|
/// `arena`, which outlives the statement.
|
|
pub fn statsRoutes(
|
|
database: *db.Db,
|
|
arena: Allocator,
|
|
since: i64,
|
|
until: i64,
|
|
) db.Error!std.ArrayList(RouteCount) {
|
|
var out: std.ArrayList(RouteCount) = .empty;
|
|
|
|
var stmt = try database.prepare(stats_routes_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, since);
|
|
try stmt.bindInt(2, until);
|
|
|
|
while (try stmt.step()) {
|
|
try out.append(arena, .{
|
|
.route = try provenance.parse(provenance.RouteKind, stmt.columnText(0)),
|
|
.source = try stmt.columnTextAllocOrNull(arena, 1),
|
|
.count = try countOf(stmt.columnInt(2)),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// How many clients `/api/stats/clients` 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 `timeseries`.
|
|
pub const ClientSeries = struct {
|
|
client: []const u8,
|
|
buckets: []const u64,
|
|
};
|
|
|
|
/// `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,
|
|
};
|
|
|
|
/// Ranked by in-window total, ties broken by address, so the cut at eight is
|
|
/// the same cut on every request over the same data.
|
|
const stats_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 stats_clients_buckets_sql =
|
|
\\SELECT client_ip, (timestamp - ?1) / ?2, count(*)
|
|
\\ FROM query_log
|
|
\\ WHERE timestamp >= ?1 AND timestamp < ?3
|
|
\\ GROUP BY 1, 2
|
|
;
|
|
|
|
/// Per-client counts over `bucket_count` buckets of `bucket_seconds` starting
|
|
/// at `since`. Everything outside the top `max_client_series` sums into
|
|
/// `other`, so the series still add up to the window's total.
|
|
///
|
|
/// Two statements, one ranking and one bucketing: the caller runs them inside
|
|
/// one read transaction, so the rank and the buckets describe one state.
|
|
pub fn statsClients(
|
|
database: *db.Db,
|
|
arena: Allocator,
|
|
since: i64,
|
|
bucket_seconds: u32,
|
|
bucket_count: u32,
|
|
) db.Error!ClientsBreakdown {
|
|
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;
|
|
|
|
var names: std.ArrayList([]const u8) = .empty;
|
|
var series: std.ArrayList([]u64) = .empty;
|
|
{
|
|
var stmt = try database.prepare(stats_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(stats_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()) {
|
|
// The WHERE clause bounds the index already; the check keeps a schema
|
|
// surprise from writing past the slice.
|
|
const index = std.math.cast(usize, stmt.columnInt(1)) orelse return error.Mismatch;
|
|
if (index >= bucket_count) return error.Mismatch;
|
|
const count = try countOf(stmt.columnInt(2));
|
|
|
|
const client = stmt.columnText(0);
|
|
const target = for (names.items, series.items) |name_, buckets| {
|
|
if (std.mem.eql(u8, name_, client)) break buckets;
|
|
} else other;
|
|
target[index] += count;
|
|
}
|
|
|
|
const clients = try arena.alloc(ClientSeries, names.items.len);
|
|
for (clients, names.items, series.items) |*entry, name_, buckets| {
|
|
entry.* = .{ .client = name_, .buckets = buckets };
|
|
}
|
|
return .{ .clients = clients, .other = other };
|
|
}
|
|
|
|
fn zeroedBuckets(arena: Allocator, bucket_count: u32) Allocator.Error![]u64 {
|
|
const buckets = try arena.alloc(u64, bucket_count);
|
|
@memset(buckets, 0);
|
|
return buckets;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(&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(&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(&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(&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(&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(&database);
|
|
defer writer.deinit();
|
|
|
|
// The seeded watermark is `created_at + 1`, which is now-ish; the cutoffs
|
|
// below are all in the past, so they start out behind it.
|
|
const start = try availableSince(&database);
|
|
try writer.writeBatch(&.{ plainRow(start + 100, "a.example"), plainRow(start + 300, "b.example") });
|
|
|
|
const first = try pruneOlderThan(&database, start + 200);
|
|
try testing.expectEqual(@as(i64, 1), first.deleted);
|
|
try testing.expectEqual(start + 200, first.available_since);
|
|
try testing.expectEqual(start + 200, try availableSince(&database));
|
|
|
|
// A prune that deletes nothing still advances: the window it swept is
|
|
// covered whether or not it held rows.
|
|
const second = try pruneOlderThan(&database, start + 250);
|
|
try testing.expectEqual(@as(i64, 0), second.deleted);
|
|
try testing.expectEqual(start + 250, try availableSince(&database));
|
|
}
|
|
|
|
test "the watermark never moves backward" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
const start = try availableSince(&database);
|
|
const advanced = try pruneOlderThan(&database, start + 1000);
|
|
try testing.expectEqual(start + 1000, advanced.available_since);
|
|
|
|
// A shortened `retention_days`, a clock that stepped back, a pass with a
|
|
// stale cutoff: none of them may widen the promise the file makes.
|
|
for ([_]i64{ start + 999, start, start - 100_000, 0 }) |older| {
|
|
const result = try pruneOlderThan(&database, older);
|
|
try testing.expectEqual(start + 1000, result.available_since);
|
|
try testing.expectEqual(start + 1000, try availableSince(&database));
|
|
}
|
|
}
|
|
|
|
test "a failed delete leaves both the rows and the watermark untouched" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
var writer = try BatchWriter.init(&database);
|
|
defer writer.deinit();
|
|
|
|
const start = try availableSince(&database);
|
|
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
|
|
try database.exec(
|
|
\\CREATE TRIGGER refuse_delete BEFORE DELETE ON query_log
|
|
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
|
);
|
|
|
|
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
|
|
|
|
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
|
try testing.expectEqual(start, try availableSince(&database));
|
|
}
|
|
|
|
test "a failed watermark update leaves the rows it had already deleted" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
var writer = try BatchWriter.init(&database);
|
|
defer writer.deinit();
|
|
|
|
const start = try availableSince(&database);
|
|
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
|
|
// The delete succeeds and the advance does not. Without one transaction
|
|
// around the pair, this is the case that loses rows the watermark still
|
|
// promises.
|
|
try database.exec(
|
|
\\CREATE TRIGGER refuse_advance BEFORE UPDATE ON querylog_meta
|
|
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
|
);
|
|
|
|
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
|
|
|
|
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
|
try testing.expectEqual(start, try availableSince(&database));
|
|
}
|
|
|
|
test "a failed commit rolls back the delete and the watermark together" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
var writer = try BatchWriter.init(&database);
|
|
defer writer.deinit();
|
|
|
|
const start = try availableSince(&database);
|
|
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
|
|
|
|
// Both statements succeed and COMMIT is what fails: the advance inserts a
|
|
// `query_log` row whose `domain_id` references nothing, and
|
|
// `defer_foreign_keys` holds that violation back until the commit checks
|
|
// it (SQLite's documented semantics for the pragma; the assertions below
|
|
// observe the rollback, not the moment the check ran).
|
|
try database.exec(
|
|
\\CREATE TRIGGER break_at_commit AFTER UPDATE ON querylog_meta
|
|
\\BEGIN INSERT INTO query_log
|
|
\\ (timestamp, domain_id, client_ip, blocked, qclass, rcode,
|
|
\\ policy_action, policy_reason, route_kind)
|
|
\\VALUES (1, 999999, 'x', 0, 1, 0, 'allow', 'no_match', 'upstream'); END;
|
|
);
|
|
try database.exec("PRAGMA defer_foreign_keys = ON;");
|
|
|
|
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
|
|
|
|
// Nothing survived: not the delete, not the advance, not the row the
|
|
// trigger inserted.
|
|
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
|
try testing.expectEqual(start, try availableSince(&database));
|
|
try testing.expectEqual(
|
|
@as(i64, 0),
|
|
try database.queryInt("SELECT count(*) FROM query_log WHERE client_ip = 'x'"),
|
|
);
|
|
}
|
|
|
|
test "pruneOlderThan leaves the domains dimension table intact" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
var writer = try BatchWriter.init(&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(&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(&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(&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(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 "statsTotals aggregates the window and averages only the timed rows" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
var timed = plainRow(100, "a.example");
|
|
timed.response_time_us = 100;
|
|
var blocked_row = plainRow(150, "ads.example");
|
|
blocked_row.blocked = true;
|
|
blocked_row.policy_action = .block;
|
|
blocked_row.policy_reason = .blocklist_domain;
|
|
blocked_row.route_kind = .blocked;
|
|
blocked_row.response_time_us = 200;
|
|
var cached = plainRow(199, "b.example");
|
|
cached.client_ip = "192.0.2.99";
|
|
cached.cache_hit = true;
|
|
cached.response_time_us = null;
|
|
try seed(&database, &.{ timed, blocked_row, cached, plainRow(200, "outside.example") });
|
|
|
|
const totals = try statsTotals(&database, 100, 200);
|
|
try testing.expectEqual(@as(u64, 3), totals.queries);
|
|
try testing.expectEqual(@as(u64, 1), totals.blocked);
|
|
try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
|
|
// (100 + 200) / 2 — the untimed row is not in the divisor.
|
|
try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
|
|
}
|
|
|
|
test "statsTotals over an empty window is zeros with a null average" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
try seed(&database, &.{plainRow(100, "a.example")});
|
|
|
|
for ([_][2]i64{ .{ 500, 600 }, .{ 100, 100 } }) |window| {
|
|
const totals = try statsTotals(&database, window[0], window[1]);
|
|
try testing.expectEqual(@as(u64, 0), totals.queries);
|
|
try testing.expectEqual(@as(u64, 0), totals.blocked);
|
|
try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
|
|
try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
|
|
}
|
|
}
|
|
|
|
test "timeseries writes every bucket, including the ones with no rows" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
var blocked_row = plainRow(1020, "ads.example");
|
|
blocked_row.blocked = true;
|
|
blocked_row.policy_action = .block;
|
|
blocked_row.policy_reason = .blocklist_domain;
|
|
blocked_row.route_kind = .blocked;
|
|
var cached = plainRow(1035, "b.example");
|
|
cached.cache_hit = true;
|
|
try seed(&database, &.{
|
|
plainRow(995, "before.example"),
|
|
plainRow(1000, "a.example"),
|
|
plainRow(1009, "a.example"),
|
|
blocked_row,
|
|
cached,
|
|
plainRow(1040, "after.example"),
|
|
});
|
|
|
|
var buckets: [4]Bucket = undefined;
|
|
try testing.expectEqual(@as(usize, 4), try timeseries(&database, 1000, 10, &buckets));
|
|
|
|
// The row at 995 is before the window and the row at 1040 is past its end;
|
|
// neither lands in a bucket.
|
|
try testing.expectEqualSlices(Bucket, &.{
|
|
.{ .ts = 1000, .queries = 2, .blocked = 0, .cached = 0 },
|
|
.{ .ts = 1010, .queries = 0, .blocked = 0, .cached = 0 },
|
|
.{ .ts = 1020, .queries = 1, .blocked = 1, .cached = 0 },
|
|
.{ .ts = 1030, .queries = 1, .blocked = 0, .cached = 1 },
|
|
}, &buckets);
|
|
}
|
|
|
|
test "timeseries over an empty table still writes the whole axis" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
var buckets: [3]Bucket = undefined;
|
|
try testing.expectEqual(@as(usize, 3), try timeseries(&database, 0, 60, &buckets));
|
|
try testing.expectEqualSlices(Bucket, &.{
|
|
.{ .ts = 0, .queries = 0, .blocked = 0, .cached = 0 },
|
|
.{ .ts = 60, .queries = 0, .blocked = 0, .cached = 0 },
|
|
.{ .ts = 120, .queries = 0, .blocked = 0, .cached = 0 },
|
|
}, &buckets);
|
|
}
|
|
|
|
test "timeseries rejects a zero-width bucket and accepts an empty slice" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
var buckets: [2]Bucket = undefined;
|
|
try testing.expectError(error.Misuse, timeseries(&database, 0, 0, &buckets));
|
|
|
|
var none: [0]Bucket = undefined;
|
|
try testing.expectEqual(@as(usize, 0), try timeseries(&database, 0, 0, &none));
|
|
}
|
|
|
|
test "timeseries reports a window that does not fit an i64 rather than wrapping" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
var buckets: [4]Bucket = undefined;
|
|
try testing.expectError(
|
|
error.Misuse,
|
|
timeseries(&database, std.math.maxInt(i64) - 1, 3600, &buckets),
|
|
);
|
|
}
|
|
|
|
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;
|
|
const agg_until: i64 = agg_since + agg_width * agg_buckets;
|
|
|
|
/// 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 statsTypes(&database, arena, agg_since, agg_until)).items;
|
|
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 statsTypes(&database, arena_state.allocator(), agg_since, agg_until)).items;
|
|
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 statsRoutes(&database, arena, agg_since, agg_until)).items;
|
|
// 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 statsRoutes(&database, arena_state.allocator(), agg_since, agg_until)).items;
|
|
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 statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
|
|
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 statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
|
|
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 statsClients(&database, arena, agg_since, agg_width, agg_buckets);
|
|
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 totals = try statsTotals(&database, agg_since, agg_until);
|
|
try testing.expect(totals.queries > 0);
|
|
|
|
var typed: u64 = 0;
|
|
for ((try statsTypes(&database, arena, agg_since, agg_until)).items) |row| typed += row.count;
|
|
try testing.expectEqual(totals.queries, typed);
|
|
|
|
var routed: u64 = 0;
|
|
for ((try statsRoutes(&database, arena, agg_since, agg_until)).items) |row| routed += row.count;
|
|
try testing.expectEqual(totals.queries, routed);
|
|
|
|
var buckets: [agg_buckets]Bucket = undefined;
|
|
_ = try timeseries(&database, agg_since, agg_width, &buckets);
|
|
const clients = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
|
|
// Per bucket, not just in total: a series misaligned by one bucket would
|
|
// still sum correctly over the window.
|
|
for (buckets, 0..) |bucket, at| {
|
|
var summed: u64 = clients.other[at];
|
|
for (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 statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
|
|
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 tx = try db.ReadTx.begin(&reader);
|
|
const before = try statsTotals(&reader, agg_since, agg_since + 1000);
|
|
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 statsTotals(&reader, agg_since, agg_since + 1000);
|
|
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 statsTotals(&reader, agg_since, agg_since + 1000);
|
|
try testing.expectEqual(@as(u64, 1), after.queries);
|
|
try testing.expectEqual(pruned.available_since, try availableSince(&reader));
|
|
}
|