storage: version querylog.db and migrate it in place, never reset a healthy file
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 31m58s

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.
This commit is contained in:
2026-08-28 17:56:19 +02:00
parent c9701fae85
commit 0f01c2fbd7
25 changed files with 4312 additions and 176 deletions
+40 -25
View File
@@ -28,7 +28,9 @@ const model = @import("../config/model.zig");
const validate = @import("../config/validate.zig");
const db = @import("db.zig");
const migrations = @import("migrations.zig");
const querylog_migrations = @import("querylog_migrations.zig");
const querylog_schema = @import("querylog_schema.zig");
const querylog_versions = @import("querylog_versions.zig");
const testing = std.testing;
@@ -354,7 +356,7 @@ test "S7 case 1: querylog open on a fresh directory creates the schema" {
try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?);
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
@as(i64, querylog_versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(
@@ -387,7 +389,7 @@ test "S7 case 2: reopening a healthy querylog recreates nothing" {
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}
test "S7 case 3: a wrong user_version recreates and keeps the old file aside" {
test "S7 case 3: a version this build cannot handle refuses and touches nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
@@ -396,29 +398,42 @@ test "S7 case 3: a wrong user_version recreates and keeps the old file aside" {
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
querylog_migrations.expected_failures.begin();
defer querylog_migrations.expected_failures.end();
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
result.recreated.?,
);
// Both refusal lanes, against a checkpointed file with no sidecars beside
// it: a stamp above this build's version (a downgrade) and a stamp that is
// not a version at all (a pre-0.0.12 fingerprint, or a foreign file).
const refusals = [_]struct { stamp: i32, expected: anyerror }{
.{ .stamp = querylog_versions.current_version + 1, .expected = error.SchemaTooNew },
.{ .stamp = 0, .expected = error.SchemaUnsupported },
.{ .stamp = -7, .expected = error.SchemaUnsupported },
.{ .stamp = 603440875, .expected = error.SchemaUnsupported },
};
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
for (refusals) |lane| {
try stampUserVersion(path, lane.stamp);
try testing.expect(!try f.exists("querylog.db-wal"));
// The file was healthy: this build's schema moved, the database did not rot.
// An operator who reads "corrupt" here deletes a file that was never broken.
try testing.expect(std.mem.startsWith(u8, asides.items.items[0], "querylog.db.schema-changed-"));
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
const kept = try f.read(asides.items.items[0]);
defer testing.allocator.free(kept);
try testing.expectEqualSlices(u8, original, kept);
try testing.expectError(
lane.expected,
querylog_schema.open(io, std.Io.Dir.cwd(), path),
);
// Byte-identical, not merely "still readable": nothing was rewritten,
// no aside was made, and no fresh database was created beside it.
const after = try f.read("querylog.db");
defer testing.allocator.free(after);
try testing.expectEqualSlices(u8, original, after);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}
}
test "S7 case 4: a garbage file recreates and the garbage is preserved" {
@@ -468,11 +483,11 @@ test "S7 case 5: two recreates in the same second produce two distinct aside fil
var round: usize = 0;
while (round < 2) : (round += 1) {
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
try f.write("querylog.db", "not a database at all");
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
querylog_schema.RecreateReason.not_a_database,
result.recreated.?,
);
}
@@ -492,7 +507,7 @@ test "S7 case 6: a stale write-ahead log is removed before the fresh database is
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
try f.write("querylog.db", "not a database at all");
// Existence alone proves nothing: the fresh database turns WAL on again and
// writes its own `-wal`. The marker is what distinguishes the stale file
@@ -503,7 +518,7 @@ test "S7 case 6: a stale write-ahead log is removed before the fresh database is
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
querylog_schema.RecreateReason.not_a_database,
result.recreated.?,
);
@@ -591,7 +606,7 @@ test "S7 case 23: a locked querylog propagates Busy and is never destroyed" {
defer reopened.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), reopened.recreated);
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
@as(i64, querylog_versions.current_version),
try reopened.database.queryInt("PRAGMA user_version"),
);