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
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:
@@ -54,9 +54,24 @@ pub const c = struct {
|
||||
pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
|
||||
pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
|
||||
pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
|
||||
pub extern fn sqlite3_column_double(stmt: *c.Stmt, col: c_int) f64;
|
||||
pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
|
||||
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
|
||||
pub extern fn sqlite3_set_authorizer(db: *Sqlite3, xAuth: ?*const AuthCallback, user_data: ?*anyopaque) c_int;
|
||||
pub extern fn sqlite3_get_autocommit(db: *Sqlite3) c_int;
|
||||
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
|
||||
|
||||
/// `int (*)(void*, int, const char*, const char*, const char*, const char*)`.
|
||||
/// The four name arguments are NULL for actions that do not use them, which
|
||||
/// `SQLITE_TRANSACTION` is except for its operation name.
|
||||
pub const AuthCallback = fn (
|
||||
user_data: ?*anyopaque,
|
||||
action: c_int,
|
||||
arg1: ?[*:0]const u8,
|
||||
arg2: ?[*:0]const u8,
|
||||
database: ?[*:0]const u8,
|
||||
trigger_or_view: ?[*:0]const u8,
|
||||
) callconv(.c) c_int;
|
||||
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
|
||||
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
|
||||
};
|
||||
@@ -105,6 +120,20 @@ pub const open_flag = struct {
|
||||
pub const exrescode: c_int = 0x2000000;
|
||||
};
|
||||
|
||||
/// The authorizer verdicts and the one action code nxdns denies, from the
|
||||
/// vendored `sqlite3.h` (3.53.4). `SQLITE_DENY` fails the *prepare* with
|
||||
/// `SQLITE_AUTH`, which is what makes it a real guard: the statement never
|
||||
/// runs at all.
|
||||
pub const auth = struct {
|
||||
pub const deny: c_int = 1;
|
||||
pub const transaction: c_int = 22;
|
||||
/// `SAVEPOINT`, `RELEASE` and `ROLLBACK TO` report under this code, not
|
||||
/// under `transaction`. A savepoint at the outermost level opens a real
|
||||
/// transaction and `RELEASE` commits it, so a step using one splits the
|
||||
/// migration exactly as a bare `COMMIT` would.
|
||||
pub const savepoint: c_int = 32;
|
||||
};
|
||||
|
||||
/// Column type codes returned by `sqlite3_column_type`.
|
||||
pub const column_type = struct {
|
||||
pub const integer: c_int = 1;
|
||||
@@ -367,6 +396,7 @@ pub const Db = struct {
|
||||
/// message is read back through `sqlite3_errmsg`, so there is no
|
||||
/// `sqlite3_free` obligation.
|
||||
pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
|
||||
if (execFaultTripped(sql)) return error.Internal;
|
||||
return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
|
||||
}
|
||||
|
||||
@@ -424,6 +454,38 @@ pub const Db = struct {
|
||||
return value;
|
||||
}
|
||||
|
||||
/// False while a transaction is open on this connection. The migration
|
||||
/// runner's belt check: a step that somehow ended the runner's transaction
|
||||
/// must not be allowed to look like a success.
|
||||
pub fn inTransaction(self: *Db) bool {
|
||||
return c.sqlite3_get_autocommit(self.handle) == 0;
|
||||
}
|
||||
|
||||
/// Denies every transaction statement — `BEGIN`, `COMMIT`, `ROLLBACK`,
|
||||
/// `SAVEPOINT`, `RELEASE` — until `clearAuthorizer` runs.
|
||||
///
|
||||
/// `guard` must outlive the installed window: SQLite keeps the pointer.
|
||||
/// Install and clear are a scoped pair; the migration runner clears on
|
||||
/// every exit path, because a leaked authorizer would go on denying the
|
||||
/// ROLLBACK that cleans up after the very statement it rejected.
|
||||
pub fn denyTransactions(self: *Db, guard: *TransactionGuard) Error!void {
|
||||
guard.* = .{};
|
||||
return check(c.sqlite3_set_authorizer(self.handle, transactionAuthorizer, guard));
|
||||
}
|
||||
|
||||
/// Never fails in a way the caller can act on: passing a null callback only
|
||||
/// clears state SQLite already holds. A failure is logged and swallowed so
|
||||
/// this stays usable in `defer`.
|
||||
pub fn clearAuthorizer(self: *Db) void {
|
||||
const rc = c.sqlite3_set_authorizer(self.handle, null, null);
|
||||
if (rc != result.ok) {
|
||||
log.err("sqlite3_set_authorizer(null) returned {s} (code {d})", .{
|
||||
std.mem.span(c.sqlite3_errstr(rc)),
|
||||
rc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lastInsertRowid(self: *Db) i64 {
|
||||
return c.sqlite3_last_insert_rowid(self.handle);
|
||||
}
|
||||
@@ -442,6 +504,34 @@ pub const Db = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Records whether the authorizer installed by `Db.denyTransactions` actually
|
||||
/// rejected anything. The rejection reaches the caller as `error.Auth`, which
|
||||
/// is indistinguishable from any other authorization failure; this flag is what
|
||||
/// lets the migration runner name the real cause.
|
||||
pub const TransactionGuard = struct {
|
||||
denied: bool = false,
|
||||
};
|
||||
|
||||
fn transactionAuthorizer(
|
||||
user_data: ?*anyopaque,
|
||||
action: c_int,
|
||||
arg1: ?[*:0]const u8,
|
||||
arg2: ?[*:0]const u8,
|
||||
database: ?[*:0]const u8,
|
||||
trigger_or_view: ?[*:0]const u8,
|
||||
) callconv(.c) c_int {
|
||||
_ = arg2;
|
||||
_ = database;
|
||||
_ = trigger_or_view;
|
||||
if (action != auth.transaction and action != auth.savepoint) return result.ok;
|
||||
const guard: *TransactionGuard = @ptrCast(@alignCast(user_data.?));
|
||||
guard.denied = true;
|
||||
log.warn("migration step attempted a transaction statement: {s}", .{
|
||||
if (arg1) |op| std.mem.span(op) else "(unnamed)",
|
||||
});
|
||||
return auth.deny;
|
||||
}
|
||||
|
||||
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
|
||||
var handle: ?*c.Sqlite3 = null;
|
||||
const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
|
||||
@@ -963,6 +1053,45 @@ pub const read_tx_faults = if (builtin.is_test) struct {
|
||||
}
|
||||
} else struct {};
|
||||
|
||||
/// Fails one chosen `Db.exec` so a test can drive a failure SQLite itself will
|
||||
/// not produce on demand. Test builds only, same shape as `read_tx_seam`.
|
||||
///
|
||||
/// The migration paths this exists for — the legacy restamp and the
|
||||
/// post-commit pragma restore — run statements that always succeed against a
|
||||
/// healthy file, and their recovery behaviour is the whole point of the
|
||||
/// milestone. Matching on the SQL text rather than counting calls keeps a test
|
||||
/// naming the statement it means.
|
||||
const exec_seam = if (builtin.is_test) struct {
|
||||
var fail_matching: ?[]const u8 = null;
|
||||
} else struct {};
|
||||
|
||||
fn execFaultTripped(sql: []const u8) bool {
|
||||
if (!builtin.is_test) return false;
|
||||
const needle = exec_seam.fail_matching orelse return false;
|
||||
if (std.mem.indexOf(u8, sql, needle) == null) return false;
|
||||
exec_seam.fail_matching = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The seam's controls, for tests in this file and in the storage layer.
|
||||
pub const exec_faults = if (builtin.is_test) struct {
|
||||
/// Arms the next `Db.exec` whose SQL contains `needle` to fail with
|
||||
/// `error.Internal` before the statement reaches SQLite. One shot: it
|
||||
/// disarms itself when it trips. Pair it with `defer disarm()` so a test
|
||||
/// that never trips the fault cannot leak it into the next one.
|
||||
pub fn failNextMatching(needle: []const u8) void {
|
||||
exec_seam.fail_matching = needle;
|
||||
}
|
||||
|
||||
pub fn disarm() void {
|
||||
exec_seam.fail_matching = null;
|
||||
}
|
||||
|
||||
pub fn armed() bool {
|
||||
return exec_seam.fail_matching != null;
|
||||
}
|
||||
} else struct {};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn openMemory() Error!Db {
|
||||
@@ -1479,3 +1608,57 @@ test "a duplicate insert into a UNIQUE column returns error.Constraint" {
|
||||
try stmt.bindText(1, "only");
|
||||
try testing.expectError(error.Constraint, stmt.step());
|
||||
}
|
||||
|
||||
test "the transaction authorizer denies transaction statements and clears cleanly" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
||||
|
||||
try db.exec("BEGIN IMMEDIATE;");
|
||||
try testing.expect(db.inTransaction());
|
||||
|
||||
var guard: TransactionGuard = .{};
|
||||
try db.denyTransactions(&guard);
|
||||
// Ordinary work still runs: only the transaction statements are refused.
|
||||
try db.exec("INSERT INTO t (id) VALUES (1);");
|
||||
try testing.expect(!guard.denied);
|
||||
try testing.expectError(error.Auth, db.exec("COMMIT;"));
|
||||
try testing.expect(guard.denied);
|
||||
// The deny happens at prepare, so the transaction is still open.
|
||||
try testing.expect(db.inTransaction());
|
||||
|
||||
// SAVEPOINT and its RELEASE report under a different action code, and they
|
||||
// are the same bypass: at the outermost level they are a transaction under
|
||||
// another name, and inside one they can still discard the migration's work.
|
||||
guard.denied = false;
|
||||
try testing.expectError(error.Auth, db.exec("SAVEPOINT half_a_migration;"));
|
||||
try testing.expect(guard.denied);
|
||||
guard.denied = false;
|
||||
try testing.expectError(error.Auth, db.exec("RELEASE half_a_migration;"));
|
||||
try testing.expect(guard.denied);
|
||||
try testing.expect(db.inTransaction());
|
||||
|
||||
db.clearAuthorizer();
|
||||
// The same connection is usable again — a leaked authorizer would strand it
|
||||
// inside the transaction by denying this too.
|
||||
try db.exec("ROLLBACK;");
|
||||
try testing.expect(!db.inTransaction());
|
||||
try testing.expectEqual(@as(i64, 0), try db.queryInt("SELECT count(*) FROM t"));
|
||||
}
|
||||
|
||||
test "the exec fault seam fires once, on the statement it names" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
||||
|
||||
exec_faults.failNextMatching("COMMIT");
|
||||
defer exec_faults.disarm();
|
||||
|
||||
try db.exec("BEGIN IMMEDIATE;");
|
||||
try db.exec("INSERT INTO t (id) VALUES (1);");
|
||||
try testing.expectError(error.Internal, db.exec("COMMIT;"));
|
||||
try testing.expect(!exec_faults.armed());
|
||||
// Disarmed: the retry is a real COMMIT.
|
||||
try db.exec("COMMIT;");
|
||||
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user