milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
|
||||
const log = std.log.scoped(.db);
|
||||
@@ -844,6 +845,137 @@ pub const Tx = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// A read transaction: one consistent view of the file across several
|
||||
/// statements.
|
||||
///
|
||||
/// `BEGIN DEFERRED`, not `Tx`'s `BEGIN IMMEDIATE`. A reader that took the write
|
||||
/// lock would stall the logger and retention for the length of an HTTP
|
||||
/// response; a deferred transaction that only ever reads never upgrades, so it
|
||||
/// cannot hit the mid-way `SQLITE_BUSY` the `Tx` doc warns about. In WAL mode it
|
||||
/// pins the snapshot at the first read, which is the point: an aggregate and the
|
||||
/// coverage watermark beside it describe the same database state even when
|
||||
/// retention prunes between them.
|
||||
///
|
||||
/// Ending it is fallible, and the caller must treat it that way. A connection
|
||||
/// left inside a transaction refuses the next `BEGIN`, so a swallowed failure
|
||||
/// here does not cost one response — it costs every later one on the same
|
||||
/// connection, and they would each be a correct-looking answer over a foreign
|
||||
/// snapshot or an outright error the client was never told about.
|
||||
///
|
||||
/// ```zig
|
||||
/// var tx = try ReadTx.begin(db);
|
||||
/// errdefer tx.rollback();
|
||||
/// ... // reads only
|
||||
/// try tx.commit();
|
||||
/// ```
|
||||
pub const ReadTx = struct {
|
||||
db: *Db,
|
||||
active: bool,
|
||||
|
||||
pub fn begin(db: *Db) Error!ReadTx {
|
||||
try db.exec("BEGIN DEFERRED;");
|
||||
return .{ .db = db, .active = true };
|
||||
}
|
||||
|
||||
/// Ends the transaction, and says so. A read transaction has nothing to
|
||||
/// conflict over, so a failed COMMIT means the connection is in a state
|
||||
/// this code did not put it in: the ROLLBACK below is the attempt to hand
|
||||
/// the next caller a usable connection anyway, and the error is returned so
|
||||
/// the response it was serving fails rather than reporting success over a
|
||||
/// database whose state nobody can name.
|
||||
pub fn commit(self: *ReadTx) Error!void {
|
||||
assert(self.active);
|
||||
self.active = false;
|
||||
self.execCommit() catch |err| {
|
||||
self.reportFault("COMMIT");
|
||||
self.forceRollback();
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn execCommit(self: *ReadTx) Error!void {
|
||||
if (commitFaultTripped()) return error.Internal;
|
||||
return self.db.exec("COMMIT;");
|
||||
}
|
||||
|
||||
/// Safe in `errdefer` and after `commit`. Never returns an error: it runs
|
||||
/// on the path where something has already gone wrong, and that error is
|
||||
/// the one worth reporting.
|
||||
pub fn rollback(self: *ReadTx) void {
|
||||
if (!self.active) return;
|
||||
self.active = false;
|
||||
self.forceRollback();
|
||||
}
|
||||
|
||||
/// A ROLLBACK that fails leaves the connection inside a transaction with no
|
||||
/// way left to get it out. Every later `begin` on it fails, which is the
|
||||
/// visible symptom this reports the cause of.
|
||||
fn forceRollback(self: *ReadTx) void {
|
||||
self.db.exec("ROLLBACK;") catch {
|
||||
self.reportFault("ROLLBACK");
|
||||
};
|
||||
}
|
||||
|
||||
/// The only record that this connection may be unusable, so in a real build
|
||||
/// it is `err`. A test that deliberately causes the fault captures it
|
||||
/// instead — see `read_tx_faults`.
|
||||
fn reportFault(self: *ReadTx, comptime what: []const u8) void {
|
||||
var buf: [256]u8 = undefined;
|
||||
const message = self.db.lastError(&buf);
|
||||
if (faultCaptured()) return;
|
||||
log.err("read-transaction " ++ what ++ " failed: {s}", .{message});
|
||||
}
|
||||
};
|
||||
|
||||
/// Drives and observes the read-transaction teardown faults, which a unit test
|
||||
/// cannot arrange against a healthy SQLite connection. Test builds only; it
|
||||
/// reduces to nothing everywhere else — the rotation seam's shape
|
||||
/// (logging.zig).
|
||||
const read_tx_seam = if (builtin.is_test) struct {
|
||||
var fail_next_commit: bool = false;
|
||||
var capturing: bool = false;
|
||||
var faults: usize = 0;
|
||||
} else struct {};
|
||||
|
||||
fn commitFaultTripped() bool {
|
||||
if (!builtin.is_test) return false;
|
||||
if (!read_tx_seam.fail_next_commit) return false;
|
||||
read_tx_seam.fail_next_commit = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True when a test has said it is expecting this fault and will assert on it.
|
||||
/// Capture is opt-in for exactly one reason: the test runner fails a test that
|
||||
/// logs at `err`, so a blanket silence would turn an unexpected teardown fault
|
||||
/// in some unrelated test into a silent pass.
|
||||
fn faultCaptured() bool {
|
||||
if (!builtin.is_test) return false;
|
||||
if (!read_tx_seam.capturing) return false;
|
||||
read_tx_seam.faults += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The seam's controls, for tests in this file and in the web layer.
|
||||
pub const read_tx_faults = if (builtin.is_test) struct {
|
||||
/// Fails the next `ReadTx.commit` before it issues COMMIT, so the
|
||||
/// transaction is still open when the recovery path runs — which is the
|
||||
/// shape of a real COMMIT failure.
|
||||
pub fn failNextCommit() void {
|
||||
read_tx_seam.fail_next_commit = true;
|
||||
}
|
||||
|
||||
pub fn beginCapture() void {
|
||||
read_tx_seam.capturing = true;
|
||||
read_tx_seam.faults = 0;
|
||||
}
|
||||
|
||||
/// Stops capturing and answers how many faults were reported meanwhile.
|
||||
pub fn endCapture() usize {
|
||||
read_tx_seam.capturing = false;
|
||||
return read_tx_seam.faults;
|
||||
}
|
||||
} else struct {};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn openMemory() Error!Db {
|
||||
@@ -1029,6 +1161,99 @@ test "rollback after commit is a no-op" {
|
||||
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
||||
}
|
||||
|
||||
test "a read transaction sees one state and frees the connection when it commits" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
try applyPragmas(&db, .{});
|
||||
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
||||
try db.exec("INSERT INTO t (id) VALUES (1);");
|
||||
|
||||
var tx = try ReadTx.begin(&db);
|
||||
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
||||
try tx.commit();
|
||||
try testing.expect(!tx.active);
|
||||
// Committed, so the connection takes a fresh transaction again.
|
||||
var second = try ReadTx.begin(&db);
|
||||
second.rollback();
|
||||
try testing.expect(!second.active);
|
||||
// Idempotent, so an `errdefer` that survives a successful rollback is a
|
||||
// no-op rather than a stray ROLLBACK against the next transaction.
|
||||
second.rollback();
|
||||
|
||||
var third = try ReadTx.begin(&db);
|
||||
try third.commit();
|
||||
}
|
||||
|
||||
test "a connection left inside a transaction refuses the next begin" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
try applyPragmas(&db, .{});
|
||||
|
||||
// The poisoned connection, reached the only way it can be: a transaction
|
||||
// that was opened and never ended. This is what a swallowed COMMIT failure
|
||||
// would leave behind, and the point is that it is *loud* — the next reader
|
||||
// gets an error it must report, never a silent read outside a snapshot.
|
||||
try db.exec("BEGIN DEFERRED;");
|
||||
try testing.expectError(error.Unexpected, ReadTx.begin(&db));
|
||||
|
||||
// And it is recoverable: ending the stray transaction restores the
|
||||
// connection, which is what `commit`'s rollback attempt is reaching for.
|
||||
try db.exec("ROLLBACK;");
|
||||
var tx = try ReadTx.begin(&db);
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
test "a failed commit is reported, rolled back, and leaves the connection usable" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
try applyPragmas(&db, .{});
|
||||
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
|
||||
try db.exec("INSERT INTO t (id) VALUES (1);");
|
||||
|
||||
read_tx_faults.beginCapture();
|
||||
defer _ = read_tx_faults.endCapture();
|
||||
|
||||
var tx = try ReadTx.begin(&db);
|
||||
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
||||
|
||||
// The failure lands where a real one does: the transaction is still open
|
||||
// when the recovery path runs.
|
||||
read_tx_faults.failNextCommit();
|
||||
try testing.expectError(error.Internal, tx.commit());
|
||||
try testing.expect(!tx.active);
|
||||
|
||||
// Exactly one fault: the COMMIT. The ROLLBACK behind it succeeded, which is
|
||||
// the whole point of attempting it.
|
||||
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
|
||||
|
||||
// The connection is not poisoned — the next reader gets a transaction
|
||||
// rather than inheriting the fault.
|
||||
read_tx_faults.beginCapture();
|
||||
var next = try ReadTx.begin(&db);
|
||||
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
|
||||
try next.commit();
|
||||
try testing.expectEqual(@as(usize, 0), read_tx_faults.endCapture());
|
||||
}
|
||||
|
||||
test "an armed commit fault fires once and no further" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
try applyPragmas(&db, .{});
|
||||
|
||||
read_tx_faults.beginCapture();
|
||||
defer _ = read_tx_faults.endCapture();
|
||||
read_tx_faults.failNextCommit();
|
||||
|
||||
var first = try ReadTx.begin(&db);
|
||||
try testing.expectError(error.Internal, first.commit());
|
||||
|
||||
// The seam disarms itself, so it cannot leak into a later test in the same
|
||||
// binary and fail a commit nobody asked to fail.
|
||||
var second = try ReadTx.begin(&db);
|
||||
try second.commit();
|
||||
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
|
||||
}
|
||||
|
||||
test "a row-producing statement reports its row through step" {
|
||||
var db = try openMemory();
|
||||
defer db.close();
|
||||
|
||||
Reference in New Issue
Block a user