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.
This commit is contained in:
+59
-63
@@ -58,6 +58,7 @@ const model = @import("config/model.zig");
|
||||
const pause = @import("server/pause.zig");
|
||||
const queries_repo = @import("storage/repositories/queries_repo.zig");
|
||||
const query_sink = @import("server/query_sink.zig");
|
||||
const querylog_migrations = @import("storage/querylog_migrations.zig");
|
||||
const querylog_schema = @import("storage/querylog_schema.zig");
|
||||
const rate_limiter = @import("server/rate_limiter.zig");
|
||||
const reconcile = @import("config/reconcile.zig");
|
||||
@@ -1316,9 +1317,9 @@ test "the recreated detail names the aside and the new coverage start" {
|
||||
var buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
|
||||
try std.testing.expectEqualStrings(
|
||||
"previous file kept as 'querylog.db.schema-changed-1700000000'; " ++
|
||||
"previous file kept as 'querylog.db.quick-check-failed-1700000000'; " ++
|
||||
"query history is available from 1700000001",
|
||||
recreatedDetail(&buf, "querylog.db.schema-changed-1700000000", 1700000001),
|
||||
recreatedDetail(&buf, "querylog.db.quick-check-failed-1700000000", 1700000001),
|
||||
);
|
||||
|
||||
// A fresh file that will not answer is a separate failure; the line still
|
||||
@@ -1437,7 +1438,7 @@ const m29_ddl: [:0]const u8 =
|
||||
\\VALUES (1, unixepoch(), unixepoch() + 1);
|
||||
;
|
||||
|
||||
test "an m29 query log is set aside and recreated without the upstream-history tables" {
|
||||
test "an m29 query log refuses the startup and is left exactly as it is" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
@@ -1448,10 +1449,6 @@ test "an m29 query log is set aside and recreated without the upstream-history t
|
||||
var path_buf: [256]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
// The fixture is only worth anything while it is still a *different*
|
||||
// schema from this build's, and one that carries the deleted tables.
|
||||
try testing.expect(!std.mem.eql(u8, m29_ddl, querylog_schema.ddl));
|
||||
@@ -1461,9 +1458,8 @@ test "an m29 query log is set aside and recreated without the upstream-history t
|
||||
// edit to the literal cannot satisfy by changing what it is compared to.
|
||||
try testing.expectEqual(m29_fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(m29_ddl))));
|
||||
|
||||
// A healthy m29 file, stamped with the fingerprint m29's own DDL produced
|
||||
// and backdated so its coverage promise is visibly the older one.
|
||||
const m29_coverage = blk: {
|
||||
// A healthy m29 file, stamped with the fingerprint m29's own DDL produced.
|
||||
{
|
||||
var m29 = try db.Db.open(path, .{ .mode = .read_write_create });
|
||||
defer m29.close();
|
||||
try db.applyPragmas(&m29, .{});
|
||||
@@ -1481,43 +1477,47 @@ test "an m29 query log is set aside and recreated without the upstream-history t
|
||||
"PRAGMA user_version = {d};",
|
||||
.{m29_fingerprint},
|
||||
));
|
||||
break :blk try m29.queryInt("SELECT available_since FROM querylog_meta");
|
||||
};
|
||||
try testing.expectEqual(m29_available_since, m29_coverage);
|
||||
|
||||
var opened = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
|
||||
defer opened.database.close();
|
||||
|
||||
// Set aside under the name that says the file was healthy and this build
|
||||
// moved, and still on disk for an operator who wants it.
|
||||
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, opened.recreated.?);
|
||||
try testing.expect(std.mem.indexOf(u8, opened.aside(), ".schema-changed-") != null);
|
||||
try tmp.dir.access(io, std.fs.path.basename(opened.aside()), .{});
|
||||
|
||||
// The two tables are gone from the file this process will write to.
|
||||
for ([_][]const u8{ "upstream_targets", "upstream_minute", "idx_upstream_minute_ts" }) |name| {
|
||||
var stmt = try opened.database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, name);
|
||||
try testing.expect(try stmt.step());
|
||||
try testing.expectEqual(@as(i64, 0), stmt.columnInt(0));
|
||||
}
|
||||
|
||||
// Coverage restarts: the new file does not inherit the replaced one's
|
||||
// promise about what it can answer. Strictly newer, not merely not-older —
|
||||
// a recreation that copied the watermark across would pass the weaker test.
|
||||
const coverage = try queries_repo.availableSince(&opened.database);
|
||||
try testing.expect(coverage > m29_coverage);
|
||||
// m29 predates the version stamp entirely: its `user_version` is a CRC of a
|
||||
// schema no migration chain starts from, so the only honest answer is to
|
||||
// refuse and say so. The pre-0.0.12 contract — set it aside and start empty
|
||||
// — is gone.
|
||||
querylog_migrations.expected_failures.begin();
|
||||
defer querylog_migrations.expected_failures.end();
|
||||
try testing.expectError(
|
||||
error.SchemaUnsupported,
|
||||
querylog_schema.open(io, std.Io.Dir.cwd(), path),
|
||||
);
|
||||
|
||||
reportQuerylogRecreated(&fx.store, io, 2000, &opened, &opened.database);
|
||||
try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events"));
|
||||
try testing.expectEqualStrings(
|
||||
"fingerprint_mismatch",
|
||||
try fx.text("SELECT subject_key FROM operational_events"),
|
||||
// Nothing was renamed, nothing was created, and the file still answers for
|
||||
// itself: the operator can downgrade and keep the history.
|
||||
var entries: usize = 0;
|
||||
var it = tmp.dir.iterate();
|
||||
while (try it.next(io)) |entry| {
|
||||
try testing.expect(std.mem.startsWith(u8, entry.name, "querylog.db"));
|
||||
try testing.expect(std.mem.indexOfScalar(u8, entry.name[10..], '.') == null);
|
||||
entries += 1;
|
||||
}
|
||||
try testing.expect(entries >= 1);
|
||||
|
||||
var reopened = try db.Db.open(path, .{ .mode = .read_write_existing });
|
||||
defer reopened.close();
|
||||
try testing.expectEqual(
|
||||
@as(i64, m29_fingerprint),
|
||||
try reopened.queryInt("PRAGMA user_version"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
m29_available_since,
|
||||
try reopened.queryInt("SELECT available_since FROM querylog_meta"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try reopened.queryInt("SELECT count(*) FROM upstream_targets"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a fingerprint recreate files a resolved event naming the real aside and watermark" {
|
||||
test "a recreate files a resolved event naming the real aside and watermark" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
@@ -1539,27 +1539,17 @@ test "a fingerprint recreate files a resolved event naming the real aside and wa
|
||||
created.database.close();
|
||||
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
// A healthy file this build's DDL no longer matches, which is what an
|
||||
// upgrade that edits the schema produces.
|
||||
{
|
||||
var stamped = try db.Db.open(path, .{ .mode = .read_write_existing });
|
||||
defer stamped.close();
|
||||
var sql_buf: [64]u8 = undefined;
|
||||
try stamped.exec(try std.fmt.bufPrintZ(
|
||||
&sql_buf,
|
||||
"PRAGMA user_version = {d};",
|
||||
.{querylog_schema.fingerprint +% 1},
|
||||
));
|
||||
}
|
||||
// Real damage: corruption is the only thing that recreates now.
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
|
||||
|
||||
var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
|
||||
defer recreated.database.close();
|
||||
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, recreated.recreated.?);
|
||||
try testing.expectEqual(querylog_schema.RecreateReason.not_a_database, recreated.recreated.?);
|
||||
|
||||
reportQuerylogRecreated(&fx.store, io, 2000, &recreated, &recreated.database);
|
||||
|
||||
try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events"));
|
||||
try testing.expectEqualStrings("fingerprint_mismatch", try fx.text("SELECT subject_key FROM operational_events"));
|
||||
try testing.expectEqualStrings("not_a_database", try fx.text("SELECT subject_key FROM operational_events"));
|
||||
try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
|
||||
// One-shot: already over when it is filed, so it never becomes an open
|
||||
// episode `/api/health` counts.
|
||||
@@ -1612,18 +1602,17 @@ test "a recreate under a long data directory keeps the watermark and a usable na
|
||||
|
||||
{
|
||||
var created = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
|
||||
defer created.database.close();
|
||||
var sql_buf: [64]u8 = undefined;
|
||||
try created.database.exec(try std.fmt.bufPrintZ(
|
||||
&sql_buf,
|
||||
"PRAGMA user_version = {d};",
|
||||
.{querylog_schema.fingerprint +% 1},
|
||||
));
|
||||
created.database.close();
|
||||
}
|
||||
{
|
||||
var deep_dir = try tmp.dir.openDir(io, nested, .{});
|
||||
defer deep_dir.close(io);
|
||||
try deep_dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
|
||||
}
|
||||
|
||||
var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
|
||||
defer recreated.database.close();
|
||||
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, recreated.recreated.?);
|
||||
try testing.expectEqual(querylog_schema.RecreateReason.not_a_database, recreated.recreated.?);
|
||||
const line_overhead = "previous file kept as ''; query history is available from ".len;
|
||||
try testing.expect(recreated.aside().len + line_overhead > events.Store.max_detail_len);
|
||||
|
||||
@@ -1682,6 +1671,13 @@ test "run maps a rejected configuration to exit 2 and everything else to exit 1"
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.ParseZon));
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.NoUsableUpstreams));
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.BadCertificate));
|
||||
// The query-log schema refusals: `run` is the only command that reaches
|
||||
// them, and exit 1 would put a deliberate refusal under the unit's
|
||||
// `Restart=on-failure`.
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.SchemaTooNew));
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.SchemaUnsupported));
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MigrationFailed));
|
||||
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MigrationBackupFailed));
|
||||
try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.AccessDenied));
|
||||
try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.OutOfMemory));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user