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
+826 -33
View File
@@ -57,6 +57,13 @@ const http = std.http;
/// the expression that computes it.
const querylog_schema = @import("querylog_schema");
/// The migration metadata the gates below judge: the supported version range
/// and the step chain, as `querylog_versions.zig` declares it and
/// `querylog_schema.open` runs it. Reached through `production_plan` rather
/// than as a second module because `querylog_schema.zig` already imports that
/// file, and one source file cannot belong to two modules.
const querylog_versions = querylog_schema.production_plan;
const max_input_bytes = 1 << 30;
/// The only repository this program can ever act on. There is no flag for it:
@@ -561,9 +568,283 @@ fn disclosesHistoryReset(section: []const u8) bool {
return std.mem.indexOf(u8, section, history_reset_phrase) != null;
}
/// The file whose DDL decides whether `querylog.db` survives an upgrade.
/// The phrase a changelog section must carry to release a MIGRATION. It is the
/// other operator-facing consequence: the history survives, and the first start
/// after the upgrade rewrites the file to get there.
const migration_phrase = "migrates your query log in place";
fn disclosesMigration(section: []const u8) bool {
return std.mem.indexOf(u8, section, migration_phrase) != null;
}
/// The heading under which an explicit break tells the operator how to get
/// their history back. A break is allowed; a break with nowhere to turn is not.
const restore_heading = "### Restoring your query history";
/// Whether the section carries `restore_heading` AND something under it. An
/// empty section under the heading is the failure mode this exists to catch:
/// the heading alone would satisfy a substring check while telling the operator
/// nothing at all.
fn disclosesRestoreInstructions(section: []const u8) bool {
var lines = std.mem.splitScalar(u8, section, '\n');
var under_heading = false;
while (lines.next()) |raw| {
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
if (under_heading) {
if (std.mem.startsWith(u8, line, "#")) return false;
if (!isBlank(line)) return true;
continue;
}
if (std.mem.eql(u8, line, restore_heading)) under_heading = true;
}
return false;
}
// ---------------------------------------------------------------------------
// the two migration gates (specs/milestone-38.md B.2)
// ---------------------------------------------------------------------------
/// A file that is immutable once released, and what became of it in this tree.
///
/// The gate never sees the bytes. Reading two revisions of a file is the
/// driver's job; deciding what a difference means is a pure function of these
/// three states, which is what makes every rule below a unit test.
const ShippedFile = struct {
kind: enum { step, fixture },
path: []const u8,
status: enum { identical, differs, missing },
};
/// One link of the chain this build ships: the bytes `querylog_versions.step_sql`
/// carries for it, and the bytes of the tree file it is supposed to be an
/// `@embedFile` of.
///
/// The pair is what makes "a step is a SQL file, period" checkable. Counting
/// steps proves only that the chain is the right LENGTH; comparing these two
/// byte strings proves each link is the frozen file the previous release can be
/// diffed against, so inline SQL, a reordered chain and an edited file all fail.
const ChainStep = struct {
embedded: []const u8,
/// The tree's `src/storage/migrations/v<from>.sql`, or null when that file
/// does not exist.
on_disk: ?[]const u8,
};
/// Everything the gates judge: the tree's migration metadata, the previous
/// release's, whether the schema text moved, what became of the files the
/// previous release froze, and the changelog section for this version.
const GateInput = struct {
ddl_changed: bool,
current_version: i32,
minimum_version: i32,
legacy_fingerprint: i32,
/// The chain in `step_sql` order: `chain[i]` migrates
/// `minimum_version + i` to `+ i + 1`.
chain: []const ChainStep,
prev_version: i32,
prev_minimum: i32,
/// One entry per step file and fixture file the PREVIOUS tag shipped.
shipped: []const ShippedFile,
/// The versions in the tree that have BOTH halves of a fixture pair.
fixture_versions: []const i32,
/// The `## [<version>]` section, or empty when CHANGELOG.md could not be
/// read — which fails every rule that needs a disclosure, on purpose.
changelog_section: []const u8,
};
/// Which lane, if any, a schema text change is released under.
const Gate1 = enum {
/// The DDL is byte-identical to the previous release's, so this gate has
/// nothing to say. Gate 2 still runs.
unchanged,
migration_lane,
break_lane,
/// The schema moved under neither lane. This is the v0.0.9 failure.
no_lane,
};
/// The metadata a release can only have by being an explicit break: a new
/// version, no way back from the previous one, and a changelog that says so and
/// says how to recover.
fn isExplicitBreak(in: GateInput) bool {
return in.current_version > in.prev_version and
in.minimum_version == in.current_version and
disclosesHistoryReset(in.changelog_section) and
disclosesRestoreInstructions(in.changelog_section);
}
fn gate1(in: GateInput) Gate1 {
if (!in.ddl_changed) return .unchanged;
// `prev_minimum <= prev_version` is what makes the previous release's files
// reachable. An explicit break sets `minimum == current > prev_version`, so
// it fails this test and can never wear the migration lane.
const chain_spans_range = in.chain.len == stepsBetween(in.minimum_version, in.current_version);
if (in.current_version > in.prev_version and
in.prev_version >= in.minimum_version and
chain_spans_range) return .migration_lane;
if (isExplicitBreak(in)) return .break_lane;
return .no_lane;
}
/// How many steps a contiguous chain from `from` to `to` has. Zero when the
/// range is empty or inverted, so a regressed version cannot produce a negative
/// count that would wrap.
fn stepsBetween(from: i32, to: i32) usize {
if (to <= from) return 0;
return @intCast(to - from);
}
/// Everything Gate 2 refuses. It runs whether or not the DDL moved: a
/// data-only migration and an edit to a released step file both leave the
/// schema text alone.
const Gate2Reason = enum {
step_edited,
step_missing,
step_has_no_file,
step_not_its_file,
fixture_edited,
fixture_missing,
fixture_pair_absent,
legacy_fingerprint_edited,
version_regressed,
minimum_regressed,
minimum_raised_without_break,
bump_without_step_or_break,
migration_undisclosed,
};
const Gate2Problem = struct {
reason: Gate2Reason,
/// The file or version the reason is about, for the message. Empty when the
/// reason is about the metadata as a whole.
subject: []const u8 = "",
};
/// The literal `querylog_versions.legacy_fingerprint` is frozen forever:
/// editing it strands every 0.0.12/0.0.13 file that has not yet been opened by
/// a migration-aware build. The gate holds the same number the module does.
const frozen_legacy_fingerprint: i32 = 1975011655;
fn gate2(arena: Allocator, in: GateInput) ?Gate2Problem {
if (in.legacy_fingerprint != frozen_legacy_fingerprint) {
return .{ .reason = .legacy_fingerprint_edited };
}
for (in.shipped) |file| {
const reason: ?Gate2Reason = switch (file.status) {
.identical => null,
.differs => switch (file.kind) {
.step => .step_edited,
.fixture => .fixture_edited,
},
.missing => switch (file.kind) {
.step => .step_missing,
.fixture => .fixture_missing,
},
};
if (reason) |r| return .{ .reason = r, .subject = file.path };
}
// Every link of the chain is the frozen file at its own index. The path is
// computed here rather than taken from the input, so a step can only clear
// this rule by being the `@embedFile` of the one file the next release will
// byte-compare against its predecessor.
for (in.chain, 0..) |step, index| {
const from = in.minimum_version + @as(i32, @intCast(index));
const path = std.fmt.allocPrint(arena, "{s}/v{d}.sql", .{ migrations_dir, from }) catch @panic("OOM");
const on_disk = step.on_disk orelse return .{ .reason = .step_has_no_file, .subject = path };
if (!std.mem.eql(u8, on_disk, step.embedded)) {
return .{ .reason = .step_not_its_file, .subject = path };
}
}
var version = in.minimum_version;
while (version <= in.current_version) : (version += 1) {
if (std.mem.indexOfScalar(i32, in.fixture_versions, version) == null) {
return .{
.reason = .fixture_pair_absent,
.subject = std.fmt.allocPrint(arena, "{d}", .{version}) catch @panic("OOM"),
};
}
}
if (in.current_version < in.prev_version) return .{ .reason = .version_regressed };
if (in.minimum_version < in.prev_minimum) return .{ .reason = .minimum_regressed };
// Raising the minimum drops support for schemas the previous release
// carried. That is allowed exactly once per break and never quietly, and
// the DDL fingerprint has no say in it — a break can leave the text alone.
if (in.minimum_version > in.prev_minimum and !isExplicitBreak(in)) {
return .{ .reason = .minimum_raised_without_break };
}
if (in.current_version > in.prev_version) {
const new_steps = in.chain.len > stepsBetween(in.prev_minimum, in.prev_version);
const a_break = in.minimum_version == in.current_version;
if (!new_steps and !a_break) return .{ .reason = .bump_without_step_or_break };
// A break discloses under Gate 1's break lane instead: its history does
// not migrate, it is thrown away.
if (new_steps and !a_break and !disclosesMigration(in.changelog_section)) {
return .{ .reason = .migration_undisclosed };
}
}
return null;
}
/// The file whose DDL decides what shape `querylog.db` has.
const querylog_schema_path = "src/storage/querylog_schema.zig";
/// The file whose constants decide whether an existing `querylog.db` survives
/// the upgrade, and how.
const querylog_versions_path = "src/storage/querylog_versions.zig";
const migrations_dir = "src/storage/migrations";
const fixtures_dir = "src/storage/testdata";
/// A `pub const <name>: i32 = <literal>;` out of any revision of
/// `querylog_versions.zig`, read as text for the same reason `extractDdl` reads
/// the DDL as text: the previous release's copy only exists as `git show`
/// output. Null when the declaration is absent or is not a plain literal, which
/// is a refusal rather than a default — guessing a version would let a gate
/// pass a release it never measured.
fn extractVersionConst(file_text: []const u8, name: []const u8) ?i32 {
var lines = std.mem.splitScalar(u8, file_text, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
var prefix_buf: [64]u8 = undefined;
const prefix = std.fmt.bufPrint(&prefix_buf, "pub const {s}: i32 = ", .{name}) catch return null;
if (!std.mem.startsWith(u8, line, prefix)) continue;
const rest = line[prefix.len..];
const end = std.mem.indexOfScalar(u8, rest, ';') orelse return null;
var digits: [32]u8 = undefined;
var len: usize = 0;
for (std.mem.trim(u8, rest[0..end], " \t")) |ch| {
if (ch == '_') continue;
if (len == digits.len) return null;
digits[len] = ch;
len += 1;
}
return std.fmt.parseInt(i32, digits[0..len], 10) catch null;
}
return null;
}
/// The version a fixture path names, for either half of a pair. Null for any
/// name that is not one, so an unrelated file in `testdata/` is ignored rather
/// than parsed into a version that does not exist.
fn fixtureVersionOf(name: []const u8) ?i32 {
const prefix = "querylog-v";
if (!std.mem.startsWith(u8, name, prefix)) return null;
const rest = name[prefix.len..];
const dash = std.mem.indexOfScalar(u8, rest, '-') orelse return null;
const suffix = rest[dash..];
if (!std.mem.eql(u8, suffix, "-schema.sql") and !std.mem.eql(u8, suffix, "-data.sql")) return null;
return std.fmt.parseInt(i32, rest[0..dash], 10) catch null;
}
/// The declaration line the DDL follows, matched whole so no other `ddl` in the
/// file can be mistaken for it.
const ddl_declaration = "pub const ddl: [:0]const u8 =";
@@ -1561,22 +1842,26 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
return result;
}
/// Refuses a release that changes the querylog schema without saying so.
/// Refuses a release whose querylog schema or migration metadata moved without
/// the release saying what that costs the operator.
///
/// `querylog.db` is never migrated: the server compares the file's stamped
/// fingerprint against this build's and, on a mismatch, sets the file aside and
/// creates an empty one. Every query the operator ever logged is gone on the
/// first start after the upgrade. v0.0.9 shipped exactly that while its
/// announcement claimed no such change, which is what this check exists to stop.
/// TWO INDEPENDENT GATES, both measured against the previous release TAG rather
/// than the last commit, because the tag is what an operator upgrades from.
///
/// The comparison is between the DDL of the previous release tag and this
/// tree's, so it measures the release, not the last commit. Every step that can
/// fail — listing the tags, reading the old file, parsing it — is a refusal
/// naming the step: a gate that cannot tell whether the schema moved must not
/// report that it did not.
/// Gate 1 is about the schema TEXT. A changed DDL has to be released under one
/// of exactly two lanes: a migration that carries the file forward, or an
/// explicit break that throws the history away and says how to get it back.
/// v0.0.9 shipped a silent break while its announcement claimed no such change,
/// which is what this gate exists to stop.
///
/// Gate 2 is about the migration METADATA, and it runs whether or not the text
/// moved: a data-only migration, an edit to a step that has already shipped, an
/// edited fixture and a quietly raised minimum all leave the DDL alone.
///
/// Every step that can fail — listing the tags, reading the old files, parsing
/// them — is a refusal naming the step. A gate that cannot tell whether
/// something moved must not report that it did not.
fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]const u8) !void {
const current = querylog_schema.fingerprint;
const tags = try gitCapture(ctx, &.{ "git", "ls-remote", "--tags", "origin" }, git_network_timeout_s);
if (!tags.ok()) {
ctx.soft("schema-gate", "`git ls-remote --tags origin` exited {d}: {s}", .{
@@ -1611,33 +1896,184 @@ fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]cons
});
return;
};
const old = querylog_schema.fingerprintOf(old_ddl);
const old_fingerprint = querylog_schema.fingerprintOf(old_ddl);
const current_fingerprint = querylog_schema.fingerprint;
if (old == current) {
ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{ previous_tag, current });
return;
// The previous release's metadata. `querylog_versions.zig` did not exist
// before milestone 38, and every file such a release created is a version-1
// file — that is what the legacy fingerprint stands for — so an absent
// module is 1 and 1 rather than a refusal. The object itself is known good
// by now: the DDL above came out of it.
var prev_version: i32 = 1;
var prev_minimum: i32 = 1;
const old_versions = try gitCapture(ctx, &.{
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_versions_path }),
}, git_local_timeout_s);
if (old_versions.ok()) {
prev_version = extractVersionConst(old_versions.stdout, "current_version") orelse {
ctx.soft("schema-gate", "cannot read `current_version` out of {s}:{s} ({s})", .{
previous.object, querylog_versions_path, previous_tag,
});
return;
};
prev_minimum = extractVersionConst(old_versions.stdout, "minimum_supported_version") orelse {
ctx.soft("schema-gate", "cannot read `minimum_supported_version` out of {s}:{s} ({s})", .{
previous.object, querylog_versions_path, previous_tag,
});
return;
};
} else {
ctx.note("schema-gate: {s} predates {s}, so it is read as schema version 1", .{
previous_tag, querylog_versions_path,
});
}
const source = changelog orelse {
const shipped = frozenFiles(ctx, previous.object, previous_tag) catch |err| switch (err) {
error.CheckFailed => return,
else => return err,
};
const in: GateInput = .{
.ddl_changed = old_fingerprint != current_fingerprint,
.current_version = querylog_versions.current,
.minimum_version = querylog_versions.minimum,
.legacy_fingerprint = querylog_versions.legacy_fingerprint,
.chain = treeChain(ctx),
.prev_version = prev_version,
.prev_minimum = prev_minimum,
.shipped = shipped,
.fixture_versions = treeFixtureVersions(ctx),
.changelog_section = if (changelog) |source| changelogSection(source, version) orelse "" else "",
};
if (changelog == null) {
// The changelog check already reported why it could not be read; this
// reports what that costs, because the gate has no way to clear itself.
ctx.soft("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and CHANGELOG.md could not be read to check the disclosure", .{
previous_tag, old, current,
// reports what that costs, because neither gate can clear itself
// without the disclosure it is looking for.
ctx.soft("schema-gate", "CHANGELOG.md could not be read, so no disclosure can be checked", .{});
}
switch (gate1(in)) {
.unchanged => ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{
previous_tag, current_fingerprint,
}),
.migration_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and schema version {d} migrates to {d} in place", .{
previous_tag, old_fingerprint, current_fingerprint, prev_version, in.current_version,
}),
.break_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) as an explicit break to schema version {d}, and the `## [{s}]` section says so and says how to recover", .{
previous_tag, old_fingerprint, current_fingerprint, in.current_version, version,
}),
.no_lane => ctx.soft(
"schema-gate",
"the querylog schema changed since {s} ({d} to {d}) under neither lane. Either ship a migration (raise `current_version` above {d}, keeping `minimum_supported_version` at or below it, with a step per version) or declare an explicit break (`minimum_supported_version == current_version`) and give the `## [{s}]` section both the phrase '{s}' and a `{s}` section with recovery steps",
.{ previous_tag, old_fingerprint, current_fingerprint, prev_version, version, history_reset_phrase, restore_heading },
),
}
const problem = gate2(ctx.arena, in) orelse {
ctx.pass("schema-gate-metadata", "the migration metadata is consistent with {s}: schema versions {d}..{d}, {d} step(s), every released step and fixture untouched", .{
previous_tag, in.minimum_version, in.current_version, in.chain.len,
});
return;
};
const section = changelogSection(source, version) orelse "";
if (!disclosesHistoryReset(section)) {
ctx.soft(
"schema-gate",
"the querylog schema changed since {s} ({d} to {d}), so the first start after this release sets querylog.db aside and creates an empty one; say so in the `## [{s}]` section, which must contain the phrase '{s}'",
.{ previous_tag, old, current, version, history_reset_phrase },
);
return;
switch (problem.reason) {
.step_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released migration step is immutable, so add a new step instead", .{ problem.subject, previous_tag }),
.step_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released migration step is immutable and every operator still below its target needs it", .{ problem.subject, previous_tag }),
.step_has_no_file => ctx.soft("schema-gate-metadata", "step {s} of the chain has no `{s}`; a step is a SQL file and nothing else, so inline SQL leaves the next release nothing to byte-compare and no operator a way to audit what ran", .{ problem.subject, problem.subject }),
.step_not_its_file => ctx.soft("schema-gate-metadata", "the chain's bytes for `{s}` are not that file's bytes; every step is the `@embedFile` of its own `v<from>.sql`, so rebuild the chain from the files rather than editing one side of the pair", .{problem.subject}),
.fixture_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released fixture is the file the next migration is proved against, so a new schema version ships a NEW pair", .{ problem.subject, previous_tag }),
.fixture_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released fixture is immutable", .{ problem.subject, previous_tag }),
.fixture_pair_absent => ctx.soft("schema-gate-metadata", "schema version {s} is supported but has no `{s}/querylog-v{s}-schema.sql` and `-data.sql` pair; every version in {d}..{d} needs one", .{ problem.subject, fixtures_dir, problem.subject, in.minimum_version, in.current_version }),
.legacy_fingerprint_edited => ctx.soft("schema-gate-metadata", "`legacy_fingerprint` is {d}, not the frozen {d}; it is the literal stamp the 0.0.12 and 0.0.13 binaries wrote, and changing it strands every such file that no migration-aware build has opened yet", .{ in.legacy_fingerprint, frozen_legacy_fingerprint }),
.version_regressed => ctx.soft("schema-gate-metadata", "`current_version` is {d} and {s} shipped {d}; the schema version never regresses", .{ in.current_version, previous_tag, prev_version }),
.minimum_regressed => ctx.soft("schema-gate-metadata", "`minimum_supported_version` is {d} and {s} shipped {d}; this build claims to migrate files the previous one could not, with no step to do it", .{ in.minimum_version, previous_tag, prev_minimum }),
.minimum_raised_without_break => ctx.soft("schema-gate-metadata", "`minimum_supported_version` rises from {d} to {d}, which drops support for schemas {s} could open. That is only releasable as the full explicit break: `minimum_supported_version == current_version`, a `current_version` above {d}, and a `## [{s}]` section carrying both '{s}' and a `{s}` section", .{ prev_minimum, in.minimum_version, previous_tag, prev_version, version, history_reset_phrase, restore_heading }),
.bump_without_step_or_break => ctx.soft("schema-gate-metadata", "`current_version` rises from {d} to {d} with no new step file and no explicit break; a version an operator's file cannot reach and is not refused for is a silent reset", .{ prev_version, in.current_version }),
.migration_undisclosed => ctx.soft("schema-gate-metadata", "this release migrates querylog.db from schema version {d} to {d}, so the `## [{s}]` section must contain the phrase '{s}'", .{ prev_version, in.current_version, version, migration_phrase }),
}
ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and the `## [{s}]` section discloses it", .{
previous_tag, old, current, version,
});
}
/// The step and fixture files the previous tag froze, each paired with what
/// this tree did to it.
///
/// `git ls-tree` lists the tag's side; the tree's side is read off disk,
/// because a fixture added in this working copy is not in any index yet.
fn frozenFiles(ctx: *Ctx, object: []const u8, previous_tag: []const u8) ![]const ShippedFile {
const listing = try gitCapture(ctx, &.{
"git", "ls-tree", "-r", "--name-only", object, "--", migrations_dir, fixtures_dir,
}, git_local_timeout_s);
if (!listing.ok()) {
ctx.soft("schema-gate-metadata", "`git ls-tree {s}` for {s} exited {d}: {s}", .{
object, previous_tag, listing.code, std.mem.trimEnd(u8, listing.combined(ctx.arena), "\n"),
});
return CheckFailed;
}
var files: std.ArrayList(ShippedFile) = .empty;
var lines = std.mem.splitScalar(u8, listing.stdout, '\n');
while (lines.next()) |raw| {
const path = std.mem.trim(u8, raw, " \t\r");
if (path.len == 0) continue;
const kind: @FieldType(ShippedFile, "kind") = if (std.mem.startsWith(u8, path, migrations_dir ++ "/"))
.step
else if (fixtureVersionOf(std.fs.path.basename(path)) != null)
.fixture
else
// Anything else under `testdata/` belongs to some other test and
// carries no immutability promise.
continue;
const released = try gitCapture(ctx, &.{
"git", "show", ctx.fmt("{s}:{s}", .{ object, path }),
}, git_local_timeout_s);
if (!released.ok()) {
ctx.soft("schema-gate-metadata", "`git show {s}:{s}` exited {d}: {s}", .{
object, path, released.code, std.mem.trimEnd(u8, released.combined(ctx.arena), "\n"),
});
return CheckFailed;
}
const status: @FieldType(ShippedFile, "status") = blk: {
const current = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch
break :blk .missing;
break :blk if (std.mem.eql(u8, current, released.stdout)) .identical else .differs;
};
files.append(ctx.arena, .{ .kind = kind, .path = path, .status = status }) catch @panic("OOM");
}
return files.items;
}
/// The chain this build embedded, each step paired with the tree file it claims
/// to be. Reading the file is all this does; whether the two agree is Gate 2's
/// rule, and an unreadable file reads as absent so that the gate names the step
/// rather than the syscall.
fn treeChain(ctx: *Ctx) []const ChainStep {
var chain: std.ArrayList(ChainStep) = .empty;
for (querylog_versions.step_sql, 0..) |embedded, index| {
const from = querylog_versions.minimum + @as(i32, @intCast(index));
const path = ctx.fmt("{s}/v{d}.sql", .{ migrations_dir, from });
const on_disk = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch null;
chain.append(ctx.arena, .{ .embedded = embedded, .on_disk = on_disk }) catch @panic("OOM");
}
return chain.items;
}
/// The versions this tree has BOTH halves of a fixture pair for, over the range
/// the metadata claims to support. Probing the range beats listing the
/// directory: the range is what the rule is about, and a stray `querylog-v9-`
/// file for some unsupported version proves nothing either way.
fn treeFixtureVersions(ctx: *Ctx) []const i32 {
var found: std.ArrayList(i32) = .empty;
var version = querylog_versions.minimum;
while (version <= querylog_versions.current) : (version += 1) {
const schema = ctx.fmt("{s}/querylog-v{d}-schema.sql", .{ fixtures_dir, version });
const data = ctx.fmt("{s}/querylog-v{d}-data.sql", .{ fixtures_dir, version });
_ = Io.Dir.cwd().readFileAlloc(ctx.io, schema, ctx.arena, .limited(max_input_bytes)) catch continue;
_ = Io.Dir.cwd().readFileAlloc(ctx.io, data, ctx.arena, .limited(max_input_bytes)) catch continue;
found.append(ctx.arena, version) catch @panic("OOM");
}
return found.items;
}
/// What to do about a `v<version>` tag that exists locally.
@@ -2673,3 +3109,360 @@ test "a published release is only reported from a payload that carries one" {
// A missing tag_name yields the empty string, which never equals a tag.
try testing.expectEqualStrings("", jsonString(no_assets.object, "id"));
}
// ---------------------------------------------------------------------------
// the two migration gates
// ---------------------------------------------------------------------------
/// A release with nothing to declare: the schema is unchanged, the metadata is
/// the previous release's, and every frozen file is where it was. Each test
/// below changes exactly the fields its rule is about, so what it is testing is
/// what it names.
fn baseGateInput() GateInput {
return .{
.ddl_changed = false,
.current_version = 1,
.minimum_version = 1,
.legacy_fingerprint = frozen_legacy_fingerprint,
.chain = &.{},
.prev_version = 1,
.prev_minimum = 1,
.shipped = &.{},
.fixture_versions = &.{1},
.changelog_section = "",
};
}
/// Two steps of plausible SQL, and the chains a correctly authored release
/// carries them in: the embedded bytes ARE the file's bytes.
const step_v1_sql = "ALTER TABLE domains RENAME TO domains_old;\n";
const step_v2_sql = "DROP VIEW recent_queries;\n";
const one_frozen_step: []const ChainStep = &.{
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
};
const two_frozen_steps: []const ChainStep = &.{
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
.{ .embedded = step_v2_sql, .on_disk = step_v2_sql },
};
const migration_section = "This release " ++ migration_phrase ++ ", so nothing is lost.\n";
const break_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
restore_heading ++ "\n\nStop the server and move the aside file back.\n";
/// A release that migrates schema version 1 to 2: one new step, one new fixture
/// pair, and the changelog phrase that discloses it.
fn migratingGateInput() GateInput {
var in = baseGateInput();
in.ddl_changed = true;
in.current_version = 2;
in.chain = one_frozen_step;
in.fixture_versions = &.{ 1, 2 };
in.changelog_section = migration_section;
return in;
}
/// A release that abandons schema version 1 instead of migrating it.
fn breakingGateInput() GateInput {
var in = baseGateInput();
in.ddl_changed = true;
in.current_version = 2;
in.minimum_version = 2;
in.chain = &.{};
in.fixture_versions = &.{2};
in.changelog_section = break_section;
return in;
}
fn expectGate2(in: GateInput, expected: ?Gate2Reason) !void {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const problem = gate2(arena_state.allocator(), in);
if (expected) |reason| {
try testing.expectEqual(reason, (problem orelse return error.GatePassed).reason);
} else {
if (problem) |actual| {
std.debug.print("unexpected gate 2 failure: {t} ({s})\n", .{ actual.reason, actual.subject });
return error.GateFailed;
}
}
}
test "a release that touches neither the schema nor the metadata passes both gates" {
const in = baseGateInput();
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, null);
}
test "a migration is released under the migration lane" {
const in = migratingGateInput();
try testing.expectEqual(Gate1.migration_lane, gate1(in));
try expectGate2(in, null);
}
test "an explicit break is released under the break lane" {
const in = breakingGateInput();
try testing.expectEqual(Gate1.break_lane, gate1(in));
try expectGate2(in, null);
}
test "a schema change under neither lane is refused" {
var in = baseGateInput();
in.ddl_changed = true;
// The v0.0.9 shape exactly: the DDL moved and nothing else did.
try testing.expectEqual(Gate1.no_lane, gate1(in));
}
test "break metadata cannot be released as a migration" {
var in = breakingGateInput();
// `minimum == current` means the previous release's files cannot reach the
// new version at all. Saying they migrate does not make them.
in.changelog_section = migration_section;
try testing.expectEqual(Gate1.no_lane, gate1(in));
}
test "a version bump whose chain does not span the supported range is refused" {
var in = migratingGateInput();
in.current_version = 3;
in.fixture_versions = &.{ 1, 2, 3 };
// One step cannot carry a file from 1 to 3.
try testing.expectEqual(Gate1.no_lane, gate1(in));
}
test "an edited or deleted released step is refused however the version moved" {
const path = migrations_dir ++ "/v1.sql";
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
var in = migratingGateInput();
// A perfectly well-formed version append, which is exactly the case
// that must not launder an edit to a step already in operators' hands.
in.current_version = 3;
in.chain = two_frozen_steps;
in.fixture_versions = &.{ 1, 2, 3 };
in.shipped = &.{.{ .kind = .step, .path = path, .status = status }};
try testing.expectEqual(Gate1.migration_lane, gate1(in));
try expectGate2(in, if (status == .differs) .step_edited else .step_missing);
}
}
test "every step of the chain must be the frozen file at its own index" {
// Matching bytes are the whole rule, so start by proving they pass.
const frozen = migratingGateInput();
try expectGate2(frozen, null);
// A step written inline, with no `v1.sql` for the next release to compare
// against. Counting steps calls this chain complete; the byte comparison
// does not.
var inline_only = migratingGateInput();
inline_only.chain = &.{.{ .embedded = step_v1_sql, .on_disk = null }};
try testing.expectEqual(Gate1.migration_lane, gate1(inline_only));
try expectGate2(inline_only, .step_has_no_file);
// The file edited after the fact, so the binary runs SQL the audited file no
// longer contains.
var edited = migratingGateInput();
edited.chain = &.{.{ .embedded = step_v1_sql, .on_disk = step_v1_sql ++ "DROP TABLE domains;\n" }};
try expectGate2(edited, .step_not_its_file);
// And a chain listing its files out of order: index 0 must be `v1.sql`.
var reordered = migratingGateInput();
reordered.current_version = 3;
reordered.fixture_versions = &.{ 1, 2, 3 };
reordered.chain = &.{
.{ .embedded = step_v2_sql, .on_disk = step_v1_sql },
.{ .embedded = step_v1_sql, .on_disk = step_v2_sql },
};
try expectGate2(reordered, .step_not_its_file);
}
test "an edited or deleted released fixture is refused" {
const path = fixtures_dir ++ "/querylog-v1-data.sql";
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
var in = migratingGateInput();
in.shipped = &.{.{ .kind = .fixture, .path = path, .status = status }};
try expectGate2(in, if (status == .differs) .fixture_edited else .fixture_missing);
}
}
test "a supported version with no fixture pair is refused" {
var in = migratingGateInput();
// The starting fixture is there; the version being released has none, so
// the migration it ships was never proved to land anywhere.
in.fixture_versions = &.{1};
try expectGate2(in, .fixture_pair_absent);
}
test "the schema version never regresses" {
var in = baseGateInput();
in.prev_version = 3;
in.prev_minimum = 1;
in.fixture_versions = &.{1};
try expectGate2(in, .version_regressed);
}
test "a version bump with neither a step nor a break is refused" {
var in = baseGateInput();
in.current_version = 2;
in.fixture_versions = &.{ 1, 2 };
in.changelog_section = migration_section;
try expectGate2(in, .bump_without_step_or_break);
}
test "a data-only migration must disclose itself even though the schema text held still" {
var in = migratingGateInput();
in.ddl_changed = false;
in.changelog_section = "";
// Gate 1 has nothing to say, which is the whole reason Gate 2 runs
// independently of it.
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, .migration_undisclosed);
in.changelog_section = migration_section;
try expectGate2(in, null);
}
test "the supported minimum never regresses" {
var in = baseGateInput();
in.prev_minimum = 2;
in.minimum_version = 1;
in.current_version = 2;
in.prev_version = 2;
in.fixture_versions = &.{ 1, 2 };
try expectGate2(in, .minimum_regressed);
}
test "raising the minimum is only releasable as the full explicit break" {
// Dropping support for a schema is the one change that silently discards an
// operator's history, so every half-measure below is refused — including the
// one where the schema text did not move at all.
var partial = breakingGateInput();
partial.changelog_section = migration_section;
try expectGate2(partial, .minimum_raised_without_break);
var no_heading = breakingGateInput();
no_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n";
try expectGate2(no_heading, .minimum_raised_without_break);
var empty_heading = breakingGateInput();
empty_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
restore_heading ++ "\n\n## [0.0.1] - 2020-01-01\n";
try expectGate2(empty_heading, .minimum_raised_without_break);
var same_version = breakingGateInput();
same_version.current_version = 1;
same_version.minimum_version = 1;
same_version.prev_minimum = 0;
same_version.fixture_versions = &.{1};
try expectGate2(same_version, .minimum_raised_without_break);
var unchanged_ddl = breakingGateInput();
unchanged_ddl.ddl_changed = false;
unchanged_ddl.changelog_section = migration_section;
try expectGate2(unchanged_ddl, .minimum_raised_without_break);
}
test "the legacy fingerprint is frozen" {
// Recomputing the anchor from a later DDL is the plausible way it gets
// edited, so the substitute is any other CRC-shaped number.
var in = baseGateInput();
in.legacy_fingerprint = 603440875;
try expectGate2(in, .legacy_fingerprint_edited);
// And the tree's own constant is the frozen one, which is what makes the
// rule above a check on this repository rather than on its own literal.
try testing.expectEqual(frozen_legacy_fingerprint, querylog_versions.legacy_fingerprint);
// The DDL has not moved since 0.0.12, so today the anchor and the schema
// fingerprint are the same number. They are not the same THING: the anchor
// is frozen at that value forever, and the fingerprint follows the schema.
try testing.expectEqual(frozen_legacy_fingerprint, querylog_schema.fingerprint);
}
test "this tree passes both gates against itself" {
// The state every cut starts from: nothing moved since the previous
// release. A tree that cannot pass this has a metadata bug, not a
// disclosure one.
var in = baseGateInput();
in.current_version = querylog_versions.current;
in.minimum_version = querylog_versions.minimum;
in.legacy_fingerprint = querylog_versions.legacy_fingerprint;
in.prev_version = querylog_versions.current;
in.prev_minimum = querylog_versions.minimum;
// The tree's own chain, each step paired with itself: reading the file off
// disk is `treeChain`'s job and needs an `Io` this test has no business
// holding. What this covers is the metadata — the chain's LENGTH against the
// supported range — which is the part a self-test can judge.
var chain: std.ArrayList(ChainStep) = .empty;
defer chain.deinit(testing.allocator);
for (querylog_versions.step_sql) |sql| {
try chain.append(testing.allocator, .{ .embedded = sql, .on_disk = sql });
}
in.chain = chain.items;
var versions: std.ArrayList(i32) = .empty;
defer versions.deinit(testing.allocator);
var version = querylog_versions.minimum;
while (version <= querylog_versions.current) : (version += 1) {
try versions.append(testing.allocator, version);
}
in.fixture_versions = versions.items;
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, null);
}
test "a previous tag without the versions module reads as schema version 1" {
// What `git show <old tag>:src/storage/querylog_versions.zig` hands back is
// nothing at all, and the driver answers 1 and 1 — every file such a release
// created is a version-1 file, which is what the legacy fingerprint stands
// for. This proves the extractor does not invent a number from a file that
// has no such declaration.
try testing.expect(extractVersionConst("pub const ddl = \"\";\n", "current_version") == null);
try testing.expect(extractVersionConst("", "minimum_supported_version") == null);
const in = baseGateInput();
try testing.expectEqual(@as(i32, 1), in.prev_version);
try testing.expectEqual(@as(i32, 1), in.prev_minimum);
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, null);
}
test "the version constants of the file on disk are the ones the gate compiled" {
// The same round trip the DDL extractor gets: `git show` will hand this
// text to `extractVersionConst`, so the parse has to agree with the
// compiler on the file it can check.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const source = try Io.Dir.cwd().readFileAlloc(
threaded.io(),
querylog_versions_path,
arena_state.allocator(),
.limited(max_input_bytes),
);
try testing.expectEqual(querylog_versions.current, extractVersionConst(source, "current_version").?);
try testing.expectEqual(querylog_versions.minimum, extractVersionConst(source, "minimum_supported_version").?);
try testing.expectEqual(
querylog_versions.legacy_fingerprint,
extractVersionConst(source, "legacy_fingerprint").?,
);
}
test "a fixture name yields its version, and nothing else does" {
try testing.expectEqual(@as(i32, 1), fixtureVersionOf("querylog-v1-schema.sql").?);
try testing.expectEqual(@as(i32, 12), fixtureVersionOf("querylog-v12-data.sql").?);
try testing.expect(fixtureVersionOf("querylog-v1-notes.sql") == null);
try testing.expect(fixtureVersionOf("querylog-schema.sql") == null);
try testing.expect(fixtureVersionOf("config-v1-schema.sql") == null);
try testing.expect(fixtureVersionOf("querylog-vx-data.sql") == null);
}
test "restore instructions need a heading and something under it" {
try testing.expect(disclosesRestoreInstructions(break_section));
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n"));
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n### Something else\nbody\n"));
try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n"));
try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n"));
}