Files
nxdns/src/storage/querylog_fixtures.zig
T
mokhtar 272655f60c storage: version querylog.db and migrate it in place, never reset a healthy file
querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
2026-08-28 17:56:19 +02:00

536 lines
22 KiB
Zig

//! The shipped `querylog.db` fixtures, and the proof that every supported
//! schema version reaches the current one with the operator's rows intact.
//!
//! A file of its own, not a section of `querylog_migrations.zig`, because of the
//! link contract that split `querylog_versions.zig` out in the first place. The
//! assertions here need `repositories/queries_repo.zig`'s projection-coherence
//! oracle, and that file reaches across `src/` for the config and filter types;
//! importing it from `querylog_migrations.zig` would pull all of it into the
//! module `tools/cut.zig` builds `querylog_schema.zig` as, where those paths lie
//! outside the module root and do not compile.
//!
//! The fixtures themselves — `testdata/querylog-v<N>-{schema,data}.sql` — are
//! immutable once released. Every version in `[minimum_supported_version,
//! current_version]` has a pair: the current version's pair is the next
//! migration's starting point, and an explicit break ships the new baseline.
const std = @import("std");
const db = @import("db.zig");
const migrations = @import("querylog_migrations.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const querylog_schema = @import("querylog_schema.zig");
const versions = @import("querylog_versions.zig");
const testing = std.testing;
/// A temporary directory and the `querylog.db` path inside it. Deliberately not
/// `querylog_migrations.zig`'s test harness: that one builds synthetic schemas,
/// while everything here starts from the shipped fixture files.
const Harness = struct {
threaded: std.Io.Threaded,
tmp: std.testing.TmpDir,
buf: [256]u8 = undefined,
fn init() Harness {
return .{
.threaded = .init(testing.allocator, .{}),
.tmp = testing.tmpDir(.{ .iterate = true }),
};
}
fn deinit(self: *Harness) void {
self.tmp.cleanup();
self.threaded.deinit();
}
fn io(self: *Harness) std.Io {
return self.threaded.io();
}
fn path(self: *Harness) [:0]const u8 {
return std.fmt.bufPrintZ(&self.buf, ".zig-cache/tmp/{s}/querylog.db", .{self.tmp.sub_path}) catch
unreachable;
}
fn openLive(self: *Harness) !db.Db {
var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
};
/// One shipped schema version's frozen pair. Both halves are immutable once
/// released — the release cut byte-compares them against the previous tag — and
/// every version in `[minimum_supported_version, current_version]` must have a
/// pair, which the cut gate also enforces.
const Fixture = struct {
version: i32,
schema: [:0]const u8,
data: [:0]const u8,
};
const fixtures = [_]Fixture{
.{
.version = 1,
.schema = @embedFile("testdata/querylog-v1-schema.sql"),
.data = @embedFile("testdata/querylog-v1-data.sql"),
},
};
fn fixtureFor(version: i32) ?Fixture {
for (fixtures) |fixture| {
if (fixture.version == version) return fixture;
}
return null;
}
/// Writes a fixture pair to `path` and stamps it. `stamp` is a parameter rather
/// than `fixture.version` because the legacy-fingerprint file is the same
/// version-1 bytes under a different stamp.
fn writeFixture(path: [:0]const u8, fixture: Fixture, stamp: i32) !void {
var database = try db.Db.open(path, .{ .mode = .read_write_create });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(fixture.schema);
try database.exec(fixture.data);
var buf: [64]u8 = undefined;
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA user_version = {d};", .{stamp}) catch unreachable;
try database.exec(sql);
}
/// A second path in the harness's directory, for the reference databases the
/// assertions below compare against.
fn sidePath(h: *Harness, buf: []u8, name: []const u8) [:0]const u8 {
return std.fmt.bufPrintZ(buf, ".zig-cache/tmp/{s}/{s}", .{ h.tmp.sub_path, name }) catch unreachable;
}
/// A database holding nothing but the current `ddl`, which is what a file
/// created by this build is.
fn openFreshCurrent(h: *Harness, buf: []u8) !db.Db {
var database = try db.Db.open(sidePath(h, buf, "fresh.db"), .{ .mode = .read_write_create });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
/// An untouched load of `fixture`, to compare a migrated or opened file against
/// rather than restating the fixture's contents in the assertions.
fn openPristine(h: *Harness, buf: []u8, fixture: Fixture) !db.Db {
const path = sidePath(h, buf, "pristine.db");
try writeFixture(path, fixture, fixture.version);
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
/// Every row of every table, as one canonical text. Exact equality is only the
/// right question for a file no migration has reshaped; a migrated file is
/// checked by the counts and the watermark instead.
fn dumpContent(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) !void {
var tables: std.ArrayList([]u8) = .empty;
defer freeOwned(gpa, &tables);
{
var stmt = try database.prepare(
\\SELECT name FROM sqlite_schema
\\WHERE type = 'table' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
\\ORDER BY name
);
defer stmt.deinit();
// Copied out before the per-table statements step: a borrowed
// `columnText` would not survive them.
while (try stmt.step()) try tables.append(gpa, try stmt.columnTextAlloc(gpa, 0));
}
// The lines are sorted here rather than by the query: the four `bucket_*`
// tables are WITHOUT ROWID, so `ORDER BY rowid` is not available to all of
// them and no single column list is.
var lines: std.ArrayList([]u8) = .empty;
defer freeOwned(gpa, &lines);
for (tables.items) |table| {
var sql_buf: [256]u8 = undefined;
const sql = std.fmt.bufPrint(&sql_buf, "SELECT * FROM \"{s}\"", .{table}) catch unreachable;
var stmt = try database.prepare(sql);
defer stmt.deinit();
while (try stmt.step()) {
var line: std.ArrayList(u8) = .empty;
errdefer line.deinit(gpa);
try line.print(gpa, "R|{s}", .{table});
var col: c_int = 0;
const columns: c_int = db.c.sqlite3_column_count(stmt.handle);
while (col < columns) : (col += 1) {
try line.print(gpa, "|{s}", .{stmt.columnTextOrNull(col) orelse "<null>"});
}
try lines.append(gpa, try line.toOwnedSlice(gpa));
}
}
std.mem.sortUnstable([]u8, lines.items, {}, struct {
fn lessThan(_: void, a: []u8, b: []u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
for (lines.items) |line| {
try out.appendSlice(gpa, line);
try out.append(gpa, '\n');
}
}
/// One value, encoded so that no two different values can produce the same
/// bytes: a type tag, the byte length, then the bytes themselves.
///
/// Nothing here is a sentinel and nothing is escaped, which is the point. A
/// serialization that wrote NULL as `<null>` cannot tell a NULL apart from the
/// six-character string of the same name, and one that separated values with
/// `|` cannot tell `a|b` in one column from `a` and `b` in two — so a migration
/// that turned a NULL `upstream` into text, or shifted a value from one column
/// into its neighbour, would compare EQUAL to the original. The length prefix
/// makes the stream uniquely decodable, so equal encodings mean equal rows.
///
/// A float travels as its bit pattern rather than as printed digits: the
/// question here is whether the value survived, not whether it rounds the same.
fn writeValue(gpa: std.mem.Allocator, stmt: *db.Stmt, col: c_int, out: *std.ArrayList(u8)) !void {
switch (db.c.sqlite3_column_type(stmt.handle, col)) {
db.column_type.null_value => try out.print(gpa, "n0:", .{}),
db.column_type.integer => {
var buf: [24]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{d}", .{stmt.columnInt(col)}) catch unreachable;
try out.print(gpa, "i{d}:{s}", .{ text.len, text });
},
db.column_type.float => {
const bits: u64 = @bitCast(db.c.sqlite3_column_double(stmt.handle, col));
var buf: [24]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{d}", .{bits}) catch unreachable;
try out.print(gpa, "f{d}:{s}", .{ text.len, text });
},
db.column_type.blob => {
// `columnText` on a blob hands back the same bytes SQLite stores,
// which is what this compares; it is not read as text.
const bytes = stmt.columnText(col);
try out.print(gpa, "b{d}:{s}", .{ bytes.len, bytes });
},
else => {
const bytes = stmt.columnText(col);
try out.print(gpa, "t{d}:{s}", .{ bytes.len, bytes });
},
}
}
/// One query's rows, in the order the query returns them, tagged with `label` so
/// that a difference names the relation it came from. The column count is part
/// of each row for the same reason the lengths are part of each value.
fn dumpQuery(
gpa: std.mem.Allocator,
database: *db.Db,
label: []const u8,
sql: [:0]const u8,
out: *std.ArrayList(u8),
) !void {
var stmt = try database.prepare(sql);
defer stmt.deinit();
while (try stmt.step()) {
const columns: c_int = db.c.sqlite3_column_count(stmt.handle);
try out.print(gpa, "{s}:{d}:", .{ label, columns });
var col: c_int = 0;
while (col < columns) : (col += 1) {
try writeValue(gpa, &stmt, col, out);
}
try out.append(gpa, '\n');
}
}
/// The operator's data as the application means it, in an order a migration
/// cannot permute.
///
/// `q.*` rather than a column list on purpose: a migration that adds a column
/// must show that column here, and a hand-written list would quietly stop
/// covering the table the day it grows. `d.domain` rides along so that the text
/// a row names is compared, not only the id it happens to hold.
const logical_relations = [_]struct { label: []const u8, sql: [:0]const u8 }{
.{
.label = "query_log",
.sql =
\\SELECT q.*, d.domain FROM query_log q
\\JOIN domains d ON d.id = q.domain_id
\\ORDER BY q.id
,
},
.{ .label = "domains", .sql = "SELECT * FROM domains ORDER BY id" },
.{ .label = "querylog_meta", .sql = "SELECT * FROM querylog_meta ORDER BY id" },
};
fn dumpLogical(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) !void {
for (logical_relations) |relation| {
try dumpQuery(gpa, database, relation.label, relation.sql, out);
}
}
fn freeOwned(gpa: std.mem.Allocator, list: *std.ArrayList([]u8)) void {
for (list.items) |item| gpa.free(item);
list.deinit(gpa);
}
fn expectSameContent(a: *db.Db, b: *db.Db) !void {
var text_a: std.ArrayList(u8) = .empty;
defer text_a.deinit(testing.allocator);
var text_b: std.ArrayList(u8) = .empty;
defer text_b.deinit(testing.allocator);
try dumpContent(testing.allocator, a, &text_a);
try dumpContent(testing.allocator, b, &text_b);
try testing.expectEqualStrings(text_a.items, text_b.items);
}
fn rowCount(database: *db.Db, table: []const u8) !i64 {
var buf: [128]u8 = undefined;
return database.queryInt(std.fmt.bufPrint(&buf, "SELECT count(*) FROM \"{s}\"", .{table}) catch unreachable);
}
/// What must hold after a fixture has come through `openVersioned`, whatever
/// path it took: every row the operator had is still there with the same
/// CONTENT, and the projections still agree with the raw rows.
///
/// Counting rows and checking one watermark is what this used to do, and a
/// migration that shifted a timestamp, dropped a `qtype` or crossed two rows'
/// `client_ip` values passed it. The comparison is therefore the full logical
/// content of the three operator tables, `available_since` included as one
/// column of `querylog_meta` among the rest. The counts stay because a count
/// difference is the failure worth naming plainly.
///
/// The relations are named rather than derived because they are the ones holding
/// operator data; the `bucket_*` projections are derived from them, which
/// `expectProjectionsMatchRecompute` is the right check for. A future migration
/// that renames a table amends this alongside the step that does it. So does one
/// that renumbers `id` values: this asserts they survive, which every rebuild
/// written to the A.2 rule does.
fn expectFixtureSurvived(opened: *db.Db, pristine: *db.Db) !void {
for ([_][]const u8{ "query_log", "domains", "querylog_meta" }) |table| {
try testing.expectEqual(try rowCount(pristine, table), try rowCount(opened, table));
}
var opened_text: std.ArrayList(u8) = .empty;
defer opened_text.deinit(testing.allocator);
var pristine_text: std.ArrayList(u8) = .empty;
defer pristine_text.deinit(testing.allocator);
try dumpLogical(testing.allocator, opened, &opened_text);
try dumpLogical(testing.allocator, pristine, &pristine_text);
try testing.expectEqualStrings(pristine_text.items, opened_text.items);
try queries_repo.expectProjectionsMatchRecompute(opened);
}
test "the survival comparison tells a NULL apart from text that looks like one" {
// The mutation a count-and-watermark check misses entirely, and a
// sentinel-string serialization misses just as completely: one column of one
// row stops being NULL and becomes the very text the sentinel used. Every
// count, the watermark and the projections all still agree.
const fixture = fixtureFor(1) orelse return error.MissingFixture;
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, fixture.version);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var corrupted = try h.openLive();
defer corrupted.close();
try testing.expectEqual(@as(i64, 0), corrupted.changes());
try corrupted.exec(
\\UPDATE query_log SET upstream = '<null>'
\\WHERE id = (SELECT min(id) FROM query_log WHERE upstream IS NULL)
);
// The fixture has to actually carry a NULL `upstream` for this to be a test
// of anything.
try testing.expectEqual(@as(i64, 1), corrupted.changes());
// Everything the old check looked at still agrees, which is why it passed.
for ([_][]const u8{ "query_log", "domains", "querylog_meta" }) |table| {
try testing.expectEqual(try rowCount(&pristine, table), try rowCount(&corrupted, table));
}
try testing.expectEqual(
try pristine.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1"),
try corrupted.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1"),
);
try queries_repo.expectProjectionsMatchRecompute(&corrupted);
// The dumps are compared here rather than through `expectFixtureSurvived`
// so that a PASSING run stays silent: `expectEqualStrings` prints the whole
// diff before it returns its error, and this is the comparison that function
// makes.
var corrupted_text: std.ArrayList(u8) = .empty;
defer corrupted_text.deinit(testing.allocator);
var pristine_text: std.ArrayList(u8) = .empty;
defer pristine_text.deinit(testing.allocator);
try dumpLogical(testing.allocator, &corrupted, &corrupted_text);
try dumpLogical(testing.allocator, &pristine, &pristine_text);
try testing.expect(!std.mem.eql(u8, corrupted_text.items, pristine_text.items));
}
test "every supported version ships a fixture pair" {
// The cut gate enforces this against a release; the suite enforces it
// against a commit, so a version bump that forgot its fixtures fails here
// long before anyone reaches for `zig build cut`.
var version = versions.minimum_supported_version;
while (version <= versions.current_version) : (version += 1) {
try testing.expect(fixtureFor(version) != null);
}
}
test "each shipped fixture pair is coherent before any migration touches it" {
for (fixtures) |fixture| {
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, fixture.version);
var database = try h.openLive();
defer database.close();
try testing.expectEqual(
@as(i64, fixture.version),
try database.queryInt("PRAGMA user_version"),
);
// An incoherent fixture has to fail as a fixture, not later as a
// migration that appears to have corrupted the projections.
try queries_repo.expectProjectionsMatchRecompute(&database);
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM pragma_foreign_key_check"));
}
}
test "every fixture below the current version migrates to it through the shipped chain" {
// Empty while the chain is: `minimum_supported_version == current_version`
// today. It is written as the loop so that the day a step ships, the
// fixture it starts from is proved through the REAL production plan with no
// edit to this test.
var version = versions.minimum_supported_version;
while (version < versions.current_version) : (version += 1) {
const fixture = fixtureFor(version) orelse return error.MissingFixture;
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, version);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
try testing.expectEqual(
@as(i64, versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
var fresh_buf: [256]u8 = undefined;
var fresh = try openFreshCurrent(&h, &fresh_buf);
defer fresh.close();
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
try expectFixtureSurvived(&result.database, &pristine);
}
}
test "the current version's fixture pair opens on the current lane unchanged" {
// The loop above never reaches this pair, and Gate 2 of the cut requires it
// to exist. This is what proves it is a real, coherent file rather than one
// shipped to satisfy a gate.
const fixture = fixtureFor(versions.current_version) orelse return error.MissingFixture;
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, versions.current_version);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
try testing.expectEqual(
@as(i64, versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
var fresh_buf: [256]u8 = undefined;
var fresh = try openFreshCurrent(&h, &fresh_buf);
defer fresh.close();
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
try expectFixtureSurvived(&result.database, &pristine);
// No migration ran, so nothing reshaped anything: byte-for-byte the rows
// that were loaded.
try expectSameContent(&result.database, &pristine);
}
test "a fixture carrying the 0.0.12 fingerprint restamps, and refuses once the minimum rises" {
const fixture = fixtureFor(1) orelse return error.MissingFixture;
// Version 1 is the legacy fingerprint's logical version, so the pair only
// has anything to say while 1 is still supported.
if (versions.minimum_supported_version <= 1 and versions.current_version >= 1) {
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, versions.legacy_fingerprint);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
try testing.expectEqual(
@as(i64, versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
var fresh_buf: [256]u8 = undefined;
var fresh = try openFreshCurrent(&h, &fresh_buf);
defer fresh.close();
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
try expectFixtureSurvived(&result.database, &pristine);
}
// The companion, already in the suite for the day a break raises the
// minimum above 1: the same bytes under the same stamp are then a file this
// build cannot reach, and it is refused without being touched.
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, versions.legacy_fingerprint);
const after_break: querylog_schema.Plan = .{
.minimum = 2,
.current = 2,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = &.{},
};
try testing.expect(querylog_schema.classify(versions.legacy_fingerprint, after_break).action ==
.refuse_unsupported);
try testing.expect(!querylog_schema.classify(versions.legacy_fingerprint, after_break).restamp);
var handle: ?db.Db = try h.openLive();
defer if (handle) |*open_db| open_db.close();
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
try testing.expectError(
error.SchemaUnsupported,
querylog_schema.openVersioned(h.io(), std.Io.Dir.cwd(), h.path(), &handle, after_break),
);
var check = try h.openLive();
defer check.close();
try testing.expectEqual(
@as(i64, versions.legacy_fingerprint),
try check.queryInt("PRAGMA user_version"),
);
var pristine_buf: [256]u8 = undefined;
var pristine = try openPristine(&h, &pristine_buf, fixture);
defer pristine.close();
try expectSameContent(&check, &pristine);
}