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

This commit is contained in:
2026-08-22 16:45:15 +02:00
parent 17422fac21
commit 648d9b4496
89 changed files with 7222 additions and 4239 deletions
+225
View File
@@ -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();
+113 -10
View File
@@ -20,9 +20,8 @@
//! having no store.
//!
//! **Time is a parameter, not a seam.** Every method that stamps a row takes
//! `now_s` (`purge`/`purgeAll` only delete, so they take none),
//! matching `upstream/history.zig` and `storage/logger.zig`. Production callers
//! read `Clock.real`; tests pass literals. There is no clock in here and no
//! `now_s` (`purge`/`purgeAll` only delete, so they take none), matching
//! `storage/logger.zig`. Production callers read `Clock.real`; tests pass literals. There is no clock in here and no
//! function pointer standing in for one.
//!
//! **`resolve` is on the DNS hot path.** `pool.recordSuccess` calls it after
@@ -39,9 +38,15 @@ const events_repo = @import("repositories/events_repo.zig");
const log = std.log.scoped(.events);
/// Every failure episode nxdns can record. **The enum is the truth**: its
/// cardinality is what the exhaustive tests, the API and the frontend copy map
/// all count, and the dotted string is only its wire form.
/// Every failure episode nxdns can **emit**, and nothing else. **The enum is
/// the truth** about what a running process writes: the exhaustive tests count
/// it, and the dotted string is only its wire form.
///
/// It is not the whole documented union. The API and the frontend copy map hold
/// these plus `legacy_wire_codes`, because a stored row outlives its producer —
/// see there. A code whose producer is gone moves to that list rather than
/// staying here: a member here is a code `Store.report` accepts, and a report
/// under a dead code opens an episode nothing can ever resolve.
pub const Code = enum {
disk_space,
disk_probe,
@@ -52,7 +57,6 @@ pub const Code = enum {
query_log_write,
query_log_maintenance,
query_log_recreated,
upstream_history_write,
upstream_exchange,
client_names_storage,
clients_storage,
@@ -75,7 +79,6 @@ pub fn wire(code: Code) []const u8 {
.query_log_write => "query_log.write",
.query_log_maintenance => "query_log.maintenance",
.query_log_recreated => "query_log.recreated",
.upstream_history_write => "upstream_history.write",
.upstream_exchange => "upstream.exchange",
.client_names_storage => "client_names.storage",
.clients_storage => "clients.storage",
@@ -92,7 +95,6 @@ pub fn component(code: Code) []const u8 {
.blocklist_refresh, .blocklist_snapshot, .blocklist_storage => "blocklist",
.certificate_reload => "certificate",
.query_log_write, .query_log_maintenance, .query_log_recreated => "query_log",
.upstream_history_write => "upstream_history",
.upstream_exchange => "upstream",
.client_names_storage => "client_names",
.clients_storage => "clients",
@@ -101,6 +103,21 @@ pub fn component(code: Code) []const u8 {
};
}
/// Wire codes that stored rows still carry and no code emits.
///
/// They live as text, not as `Code` members, because the read path is the only
/// path that meets them: the list endpoint passes a stored code straight
/// through, and `events_repo.componentOf` derives the component from the text.
/// The one thing the store must still do with them is close whatever a past
/// release left open — see `Store.init`.
///
/// The documented event-code union is these plus every `Code`, which is what
/// keeps a real response describing an old row inside the contract.
pub const legacy_wire_codes = [_][]const u8{
// Milestone 30 deleted the upstream-minute history subsystem.
"upstream_history.write",
};
/// Fixed per emit call, not per code: the same disk monitor reports a
/// transition to `warn` as a warning and one to `critical` as an error.
///
@@ -198,6 +215,17 @@ pub const Store = struct {
pub fn init(io: std.Io, database: *db.Db, now_s: i64) db.Error!Store {
var store: Store = .{ .database = database };
// Before the mirror load and the prune, both of which would otherwise
// read a state this is about to change: an episode left open here would
// sit in the mirror forever and hold `untracked_active_count` above
// zero, and one resolved after the prune would keep its row for another
// ninety days. `now_s` is the resolution time on purpose — the store's
// own open time, not the episode's stale `last_seen`, which a database
// older than the retention window would prune in this very call.
for (legacy_wire_codes) |code| {
_ = try events_repo.resolveActiveByCode(database, now_s, code);
}
var chunk: [16]events_repo.ActiveRow = undefined;
var after: i64 = 0;
while (store.active.len < mirror_capacity) {
@@ -688,7 +716,7 @@ const testing = std.testing;
test "wire and component are exhaustive, unique and agree with each other" {
const all = std.enums.values(Code);
try testing.expectEqual(@as(usize, 15), all.len);
try testing.expectEqual(@as(usize, 14), all.len);
for (all, 0..) |code, i| {
const text = wire(code);
@@ -722,6 +750,7 @@ const Fixture = struct {
io: std.Io = undefined,
database: db.Db = undefined,
store: Store = undefined,
text_buf: [256]u8 = undefined,
fn init(self: *Fixture, now_s: i64) !void {
self.threaded = .init(testing.allocator, .{});
@@ -744,8 +773,82 @@ const Fixture = struct {
fn count(self: *Fixture, sql: []const u8) !i64 {
return self.database.queryInt(sql);
}
fn text(self: *Fixture, sql: []const u8) ![]const u8 {
var stmt = try self.database.prepare(sql);
defer stmt.deinit();
if (!try stmt.step()) return error.NoRow;
const value = stmt.columnText(0);
@memcpy(self.text_buf[0..value.len], value);
return self.text_buf[0..value.len];
}
};
test "an m29 flush-failure episode is resolved once at init and stays listable" {
var fx: Fixture = .{};
try fx.init(1000);
defer fx.deinit();
// The state an m29 database is left in: a live episode under a code that no
// producer emits any more, so nothing will ever resolve it. Seeded through
// the repository and not through `report`, because the live emitter cannot
// name this code — that it cannot is half of what m30 changed.
_ = try events_repo.insertActive(
&fx.database,
1000,
legacy_wire_codes[0],
"history",
"history",
"warning",
"Busy",
);
try testing.expectEqual(
@as(i64, 1),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
// Reopening is what m30 upgrades on. Ninety days later, so a resolution
// stamped with the episode's own `last_seen` would be pruned by this very
// call rather than left for an operator to read.
const reopened_at = 1000 + Store.resolved_retention_s + 86_400;
const upgraded = try Store.init(fx.io, &fx.database, reopened_at);
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqual(
@as(i64, reopened_at),
try fx.count("SELECT resolved_at FROM operational_events"),
);
try testing.expectEqualStrings(
"upstream_history.write",
try fx.text("SELECT code FROM operational_events"),
);
// Nothing is open, so the mirror is empty and the SQL slow path is off.
try testing.expectEqual(@as(u32, 0), upgraded.active.len);
try testing.expectEqual(@as(u32, 0), upgraded.untracked_active_count);
// A second start finds nothing left to close, and the sweep is idempotent.
const again = try Store.init(fx.io, &fx.database, reopened_at + 60);
try testing.expectEqual(@as(u32, 0), again.active.len);
try testing.expectEqual(
@as(i64, reopened_at),
try fx.count("SELECT resolved_at FROM operational_events"),
);
}
test "a legacy wire code is not something the live emitter can name" {
for (legacy_wire_codes) |legacy| {
try testing.expectEqual(@as(?Code, null), parseWire(legacy));
for (std.enums.values(Code)) |code| {
try testing.expect(!std.mem.eql(u8, legacy, wire(code)));
}
// The read path still has to answer for it, and does so from the text.
const dot = std.mem.indexOfScalar(u8, legacy, '.') orelse
return error.TestUnexpectedResult;
try testing.expectEqualStrings(legacy[0..dot], events_repo.componentOf(legacy));
try testing.expect(legacy.len <= events_repo.code_capacity);
}
}
test "a failure opens one episode and repeats of it count rather than multiply" {
var fx: Fixture = .{};
try fx.init(1000);
+384 -16
View File
@@ -30,6 +30,7 @@
//! `writer_failed`, so the loss is visible rather than silent.
const std = @import("std");
const builtin = @import("builtin");
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
@@ -335,12 +336,85 @@ const Outcome = union(enum) {
expiry: std.Io.Cancelable!void,
};
/// Where the disk gate stands right now, as a state rather than a tally.
///
/// `open` is the steady state. `gated` says the gate is holding writes back but
/// nothing has been lost to it yet, and `losing` says this episode has already
/// cost rows. A cumulative drop counter cannot say any of that: it only ever
/// grows, so a rollup computed from it would latch on the first overflow.
pub const GateEpisode = enum(u8) { open, gated, losing };
/// The episode state and the identity of the episode it belongs to, in one
/// word so a compare-and-swap can test both at once.
///
/// The identity is what the state alone cannot carry. A producer decides a row
/// is lost, stalls, and wakes after the gate has closed, reopened and closed
/// again; a bare `gated -> losing` swap would then mark an episode that has
/// cost nothing. `generation` rises every time an episode opens, so that swap
/// fails against the newer episode and the stale loss is discarded.
const Gate = packed struct(u64) {
episode: GateEpisode,
generation: u56,
const initial: Gate = .{ .episode = .open, .generation = 0 };
fn bits(self: Gate) u64 {
return @bitCast(self);
}
fn of(bits_value: u64) Gate {
return @bitCast(bits_value);
}
};
/// Parks a producer immediately before it evicts an entry, so a test can move
/// the disk gate while that producer is still inside `enqueue` and the row it
/// is about to lose is still queued.
///
/// Nothing else reaches that point. `enqueue` never suspends, so from outside a
/// gate read before its loop and one read at the eviction give the same answer,
/// and no test could tell the two apart — which is exactly the difference that
/// decides whether a discard can join an episode that opened after the producer
/// started.
///
/// Before the eviction and not after it: parking after the row is already gone
/// would let a test open an episode and then file an earlier loss under it,
/// which is the misattribution the sampling rule exists to prevent. The loss
/// has to happen inside the episode for the episode to own it. The storage
/// exists in a test build only, and `park` reduces to nothing everywhere else —
/// the settings hash seam's shape (`web/handlers/settings.zig`).
const discard_stall = if (builtin.is_test) struct {
var armed: bool = false;
var parked: std.Io.Event = .unset;
var release: std.Io.Event = .unset;
fn park(io: std.Io) void {
if (!armed) return;
parked.set(io);
release.waitUncancelable(io);
}
} else struct {
fn park(io: std.Io) void {
_ = io;
}
};
pub const Logger = struct {
cfg: model.Logging,
queue: EntryQueue,
queries_dropped: std.atomic.Value(u64),
/// When the newest drop happened, in unix seconds; 0 means none yet. Read
/// through `lastDropSeconds`, which is what turns the sentinel into a null.
last_drop_s: std.atomic.Value(i64),
rows_written: std.atomic.Value(u64),
batches_gated: std.atomic.Value(u64),
/// The gating episode `/api/health` reports as `query_history.losing`, as
/// `Gate` bits. Moved only by `gateHolds`/`gateReopened`, which the writer
/// calls as it observes the monitor, and raised to `losing` by a drop that
/// carries the identity of the episode still holding.
///
/// Read it through `gateEpisode` or `sampleGate`, never as a raw integer.
gate: std.atomic.Value(u64),
/// Set when `runWriter` gives up before it consumed anything. The queue is
/// closed and every entry counts as dropped from that point, so a caller
/// that sees this must not expect rows.
@@ -363,8 +437,10 @@ pub const Logger = struct {
.cfg = cfg,
.queue = .init(queue_buf),
.queries_dropped = .init(0),
.last_drop_s = .init(0),
.rows_written = .init(0),
.batches_gated = .init(0),
.gate = .init(Gate.initial.bits()),
.writer_failed = .init(false),
.draining = .init(false),
};
@@ -422,10 +498,17 @@ pub const Logger = struct {
if (self.queue.capacity() == 0) break;
var oldest: [1]Entry = undefined;
discard_stall.park(io);
const got = self.queue.get(io, &oldest, 0) catch break;
if (got == 1) self.countDropped(1);
// Sampled after the eviction and not before the loop: the row is
// lost on the line above, and a sample taken while the gate was
// still open would let an episode that opened during `put` escape
// being marked for a row it really cost. Reading it here cannot
// misattribute in the other direction either — a sample the gate
// outruns fails `countDropped`'s generation check.
if (got == 1) self.countDropped(io, 1, self.sampleGate());
}
self.countDropped(1);
self.countDropped(io, 1, self.sampleGate());
}
/// The writer task: owns `database` and its prepared statements for its
@@ -453,7 +536,7 @@ pub const Logger = struct {
// life of the process, so the row stays active, which is the truth.
self.reportWrite(io, "writer", "preparing the batch statements failed", @errorName(err), 0);
self.queue.close(io);
_ = self.dropRemaining(io);
_ = self.dropRemaining(io, self.sampleGate());
return;
};
defer writer.deinit();
@@ -466,17 +549,24 @@ pub const Logger = struct {
error.Closed => return,
error.Canceled => |e| return e,
};
// Before `fill`, not only inside `flush`: `fill` spends the whole
// flush interval taking entries off the queue, and a producer that
// overflows the queue during that wait is losing rows to the gate
// just as surely as the held batch is. Observing here is what makes
// the episode start cover those drops instead of misfiling them as
// ordinary overflow.
const at = self.observeGate(monitor);
const deadline = self.flushDeadline(io);
// `n` is live across both calls: entries already taken off the
// queue are lost if either one is canceled, so they must count.
var n: usize = 1;
self.fill(io, &batch, deadline, &n) catch |err| {
self.countDropped(n);
self.fill(io, &batch, deadline, &n, at) catch |err| {
self.countDropped(io, n, at);
return err;
};
self.flush(io, &writer, batch[0..n], monitor) catch |err| switch (err) {
error.Canceled => |e| {
self.countDropped(n);
self.countDropped(io, n, at);
return e;
},
// Nothing will open the gate now. Everything still queued is
@@ -484,8 +574,12 @@ pub const Logger = struct {
// announced once — a per-chunk report would write to the very
// disk that is out of space, dozens of times, on the way out.
error.GatedAtShutdown => {
self.countDropped(n);
const lost = n + self.dropRemaining(io);
// Re-sampled rather than reusing `at`: `flush` observed the
// gate again on its way to this error, so the episode that
// is costing these rows is the one holding now.
const gated_at = self.sampleGate();
self.countDropped(io, n, gated_at);
const lost = n + self.dropRemaining(io, gated_at);
scope.warn(
"query log: {d} rows dropped at shutdown, the disk gate was closed",
.{lost},
@@ -521,7 +615,7 @@ pub const Logger = struct {
/// many. The drain is uncancelable: a cancellation racing the writer's own
/// failure would otherwise abandon the buffered entries without counting
/// them.
fn dropRemaining(self: *Logger, io: std.Io) usize {
fn dropRemaining(self: *Logger, io: std.Io, at: Gate) usize {
var total: usize = 0;
var leftover: [flush_batch]Entry = undefined;
while (true) {
@@ -529,7 +623,7 @@ pub const Logger = struct {
error.Closed => break,
};
if (n == 0) break;
self.countDropped(n);
self.countDropped(io, n, at);
total += n;
}
return total;
@@ -559,13 +653,14 @@ pub const Logger = struct {
batch: *[flush_batch]Entry,
deadline: std.Io.Clock.Timestamp,
n: *usize,
at: Gate,
) std.Io.Cancelable!void {
n.* += self.drainAvailable(io, batch[n.*..]);
while (n.* < batch.len) {
const remaining = deadline.durationFromNow(io);
if (remaining.raw.nanoseconds <= 0) break;
const entry = try self.getWithin(io, remaining) orelse break;
const entry = try self.getWithin(io, remaining, at) orelse break;
batch[n.*] = entry;
n.* += 1;
n.* += self.drainAvailable(io, batch[n.*..]);
@@ -588,6 +683,7 @@ pub const Logger = struct {
self: *Logger,
io: std.Io,
budget: std.Io.Clock.Duration,
at: Gate,
) std.Io.Cancelable!?Entry {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
@@ -603,7 +699,7 @@ pub const Logger = struct {
const first = race.await() catch |err| {
// Teardown: the entry the getter already took has nowhere to go.
if (drainRace(&race)) |_| self.countDropped(1);
if (drainRace(&race)) |_| self.countDropped(io, 1, at);
return err;
};
const late = drainRace(&race);
@@ -628,6 +724,7 @@ pub const Logger = struct {
.clock = .awake,
};
while (!m.writesAllowed()) {
self.gateHolds();
// Waiting for the disk to recover is right while the process
// runs and wrong once it is stopping: nothing is going to free
// space during shutdown, so the batch is lost either way and
@@ -638,6 +735,7 @@ pub const Logger = struct {
_ = self.batches_gated.fetchAdd(1, .monotonic);
try pause.sleep(io);
}
self.gateReopened();
}
var rows: [flush_batch]queries_repo.Row = undefined;
@@ -645,7 +743,10 @@ pub const Logger = struct {
writer.writeBatch(rows[0..entries.len]) catch |err| {
scope.warn("query log batch of {d} rows dropped: {s}", .{ entries.len, @errorName(err) });
self.countDropped(entries.len);
// Sampled here, so a batch the gate already let through is counted
// against whatever episode is open now — a database failure is not
// a gating loss.
self.countDropped(io, entries.len, self.sampleGate());
self.reportWrite(io, "batch", "a query log batch was dropped", @errorName(err), entries.len);
return;
};
@@ -683,8 +784,92 @@ pub const Logger = struct {
);
}
fn countDropped(self: *Logger, n: usize) void {
/// Counts `n` lost rows, stamps the loss, and raises the episode `at` to
/// `losing` if that episode is still the current one.
///
/// `at` is sampled where the rows were lost, not here: see `Gate`. A caller
/// that lost rows outside any episode passes what `sampleGate` gave it and
/// the raise is simply a no-op.
///
/// The stamp is a second atomic rather than a field beside the count, so a
/// reader can briefly see the new total against the previous timestamp. The
/// health contract says so: `dropped_total` above zero with a null
/// `last_drop_s` is a legal, momentary answer.
fn countDropped(self: *Logger, io: std.Io, n: usize, at: Gate) void {
_ = self.queries_dropped.fetchAdd(n, .monotonic);
self.stampDrop(std.Io.Clock.real.now(io).toSeconds());
if (at.episode != .gated) return;
const losing: Gate = .{ .episode = .losing, .generation = at.generation };
_ = self.gate.cmpxchgStrong(at.bits(), losing.bits(), .acq_rel, .monotonic);
}
/// Moves the stamp forward only. Two producers can reach `countDropped` out
/// of order, and a plain store would let the older one publish its
/// timestamp over the newer drop's — a `last_drop_s` that walks backwards
/// while drops are still arriving.
fn stampDrop(self: *Logger, at_s: i64) void {
var seen = self.last_drop_s.load(.monotonic);
while (at_s > seen) {
seen = self.last_drop_s.cmpxchgWeak(seen, at_s, .monotonic, .monotonic) orelse return;
}
}
/// When the newest drop happened, or null while nothing has been dropped.
pub fn lastDropSeconds(self: *const Logger) ?i64 {
const stamped = self.last_drop_s.load(.monotonic);
return if (stamped == 0) null else stamped;
}
/// Where the gate stands right now, for a reader that only wants the state.
pub fn gateEpisode(self: *const Logger) GateEpisode {
return Gate.of(self.gate.load(.acquire)).episode;
}
/// The identity a caller must carry with rows it loses.
///
/// Take it where the loss actually happens. A producer discarding one entry
/// samples at the discard; the writer samples once at `observeGate`,
/// because the batch it is holding belongs to the episode that was open
/// while it filled. Sampling earlier than the loss hides episodes that
/// opened in between; sampling later cannot misattribute, because the
/// generation makes an outrun sample fail its swap.
fn sampleGate(self: *const Logger) Gate {
return Gate.of(self.gate.load(.acquire));
}
/// Opens a gating episode under a fresh generation, or leaves one that is
/// already holding alone — `losing` must not fall back to `gated`, and a
/// second observation of the same gate must not look like a new episode.
fn gateHolds(self: *Logger) void {
var current = self.sampleGate();
while (current.episode == .open) {
const next: Gate = .{ .episode = .gated, .generation = current.generation +% 1 };
const raced = self.gate.cmpxchgWeak(current.bits(), next.bits(), .acq_rel, .acquire) orelse
return;
current = Gate.of(raced);
}
}
/// Ends the episode, keeping its generation so the next one gets a number
/// no stale producer holds. A drop sampled during the episode that lands
/// after this reopen finds its generation gone and is discarded.
fn gateReopened(self: *Logger) void {
var current = self.sampleGate();
while (current.episode != .open) {
const next: Gate = .{ .episode = .open, .generation = current.generation };
const raced = self.gate.cmpxchgWeak(current.bits(), next.bits(), .acq_rel, .acquire) orelse
return;
current = Gate.of(raced);
}
}
/// Moves the episode to wherever the monitor says the gate is, and returns
/// the episode the caller's rows now belong to. A null monitor is no gating
/// at all, so the episode stays `open`.
fn observeGate(self: *Logger, monitor: ?*disk_monitor.Monitor) Gate {
const m = monitor orelse return self.sampleGate();
if (m.writesAllowed()) self.gateReopened() else self.gateHolds();
return self.sampleGate();
}
};
@@ -1189,7 +1374,7 @@ test "entries that arrive inside one window reach the database in one batch" {
var batch: [flush_batch]Entry = undefined;
batch[0] = try logger.queue.getOne(io);
var n: usize = 1;
try logger.fill(io, &batch, logger.flushDeadline(io), &n);
try logger.fill(io, &batch, logger.flushDeadline(io), &n, logger.sampleGate());
try testing.expectEqual(@as(usize, 6), n);
// One `writeBatch` call, which is one transaction (`queries_repo.zig`).
@@ -1214,7 +1399,7 @@ test "a zero interval takes what is queued and waits for nothing" {
var n: usize = 1;
// The queue is open and the batch has room: any non-zero interval blocks
// here until it expires. Zero returns with what was already queued.
try logger.fill(io, &batch, logger.flushDeadline(io), &n);
try logger.fill(io, &batch, logger.flushDeadline(io), &n, logger.sampleGate());
try testing.expectEqual(@as(usize, 2), n);
}
@@ -1404,6 +1589,189 @@ test "a writer that cannot prepare closes the queue and counts every entry" {
try testing.expectEqual(@as(u64, 4), logger.queries_dropped.load(.monotonic));
}
test "a queue overflow stamps the drop and leaves the gate episode open" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// No writer and no monitor: nothing consumes the queue, so the third entry
// has to displace the oldest, and no gate is involved in the loss.
var buf: [2]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
try testing.expectEqual(@as(?i64, null), logger.lastDropSeconds());
for (0..3) |i| logger.log(io, sampleEntry(@intCast(i), "overflow.example"));
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
const stamped = logger.lastDropSeconds() orelse return error.TestUnexpectedResult;
try testing.expect(stamped > 1_700_000_000);
// Cumulative loss is not a current fault: the episode never opened.
try testing.expectEqual(GateEpisode.open, logger.gateEpisode());
}
test "the gating episode opens on the gate, turns losing on a drop, and clears on recovery" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
// Small enough that the entries logged below have to displace each other,
// which is the gate-caused overflow this episode is meant to catch.
var buf: [4]Entry = undefined;
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
logger.log(io, sampleEntry(1, "held.example"));
var future = try io.concurrent(Logger.runWriter, .{
&logger,
io,
&database,
@as(?*disk_monitor.Monitor, &monitor),
});
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
var waited: usize = 0;
while (logger.batches_gated.load(.monotonic) == 0) : (waited += 1) {
try testing.expect(waited < 400);
try poll.sleep(io);
}
// The gate is holding and nothing has been lost yet, which is not a fault:
// the batch is still going to be written if the disk recovers.
try testing.expectEqual(GateEpisode.gated, logger.gateEpisode());
try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
// Now overflow the queue behind the held batch. These drops belong to the
// episode, and that is what turns it from held to losing.
for (0..32) |i| logger.log(io, sampleEntry(@intCast(i + 2), "queued.example"));
try testing.expect(logger.queries_dropped.load(.monotonic) > 0);
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
try testing.expect(logger.lastDropSeconds() != null);
// The disk recovers: the held batch goes out and the episode ends.
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
waited = 0;
while (logger.gateEpisode() != .open) : (waited += 1) {
try testing.expect(waited < 400);
try poll.sleep(io);
}
try testing.expect(logger.rows_written.load(.monotonic) > 0);
// The count keeps the history the state does not.
try testing.expect(logger.queries_dropped.load(.monotonic) > 0);
logger.shutdown(io);
try future.await(io);
}
// The two tests below drive the gate primitives directly. A threaded test
// cannot prove the absence of the race they close — it can only fail to hit it
// — so they pin the mechanism instead: the generation a drop must match, and
// the direction the stamp may move.
test "a drop sampled in one episode cannot mark the next one losing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var queue_buf: [2]Entry = undefined;
var logger: Logger = .init(.{}, &queue_buf);
logger.gateHolds();
// What a producer holds while it stalls: episode one, still holding.
const stalled_in = logger.sampleGate();
try testing.expectEqual(GateEpisode.gated, stalled_in.episode);
// The gate opens and closes again while that producer is descheduled.
logger.gateReopened();
logger.gateHolds();
const episode_two = logger.sampleGate();
try testing.expectEqual(GateEpisode.gated, episode_two.episode);
try testing.expect(episode_two.generation != stalled_in.generation);
// The stale drop still counts as a lost row — it was one — but episode two
// has cost nothing and must not be told it has.
logger.countDropped(io, 1, stalled_in);
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
try testing.expectEqual(GateEpisode.gated, logger.gateEpisode());
// A drop that really belongs to episode two does raise it.
logger.countDropped(io, 1, episode_two);
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
// And a second hold of a gate that never opened is the same episode, not a
// new one: `losing` must not fall back to `gated`.
logger.gateHolds();
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
try testing.expectEqual(episode_two.generation, logger.sampleGate().generation);
}
fn logOne(logger: *Logger, io: std.Io) void {
logger.log(io, sampleEntry(2, "second.example"));
}
test "a gate that closes mid-enqueue still gets the discard that follows it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Capacity one and no writer, so the second entry has to evict the first
// and the producer discards inside `enqueue`.
var queue_buf: [1]Entry = undefined;
var logger: Logger = .init(.{}, &queue_buf);
logger.log(io, sampleEntry(1, "first.example"));
try testing.expectEqual(GateEpisode.open, logger.gateEpisode());
discard_stall.parked = .unset;
discard_stall.release = .unset;
discard_stall.armed = true;
defer discard_stall.armed = false;
// The producer enters `enqueue` with the gate open — which is the reading a
// sample taken before the loop would keep for the rest of the call.
var producer = try io.concurrent(logOne, .{ &logger, io });
discard_stall.parked.waitUncancelable(io);
// The producer is parked with the row it will evict still on the queue, so
// the episode opens strictly before the loss rather than after it. Nothing
// has been dropped yet, and that is what makes the row this episode's.
try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
logger.gateHolds();
discard_stall.armed = false;
discard_stall.release.set(io);
producer.await(io);
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
// The assertion the pre-loop sample fails: it would still be holding
// `open`, `countDropped` would return before its swap, and the episode
// would sit at `gated` having silently cost a row.
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
}
test "the drop stamp only ever moves forward" {
var queue_buf: [2]Entry = undefined;
var logger: Logger = .init(.{}, &queue_buf);
try testing.expectEqual(@as(?i64, null), logger.lastDropSeconds());
logger.stampDrop(1_700_000_100);
try testing.expectEqual(@as(?i64, 1_700_000_100), logger.lastDropSeconds());
// A producer that read its clock earlier but arrives later: the newer drop
// already published its time and must keep it.
logger.stampDrop(1_700_000_050);
try testing.expectEqual(@as(?i64, 1_700_000_100), logger.lastDropSeconds());
logger.stampDrop(1_700_000_200);
try testing.expectEqual(@as(?i64, 1_700_000_200), logger.lastDropSeconds());
}
test "a canceled writer counts the batch it was holding" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+6 -26
View File
@@ -71,24 +71,6 @@ pub const ddl: [:0]const u8 =
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
\\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
\\
\\CREATE TABLE upstream_targets (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files
\\);
\\
\\CREATE TABLE upstream_minute (
\\ upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
\\ minute_ts INTEGER NOT NULL,
\\ successes INTEGER NOT NULL,
\\ failures INTEGER NOT NULL,
\\ last_failure_ts INTEGER,
\\ last_error TEXT,
\\ PRIMARY KEY (upstream_id, minute_ts),
\\ CHECK (successes >= 0),
\\ CHECK (failures >= 0)
\\) WITHOUT ROWID;
\\CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
\\
\\CREATE TABLE querylog_meta (
\\ id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema
\\ created_at INTEGER NOT NULL,
@@ -121,9 +103,9 @@ const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{f
///
/// - Commit never fsyncs at `synchronous = NORMAL`; the checkpoint's fsync is
/// the only guaranteed durability boundary. This moves that boundary from
/// ~40 min to ~5 h of querylog data (query rows plus upstream-history
/// minutes) under power loss or kernel panic. Typical loss stays far smaller
/// because of kernel writeback, but that is not a guarantee.
/// ~40 min to ~5 h of querylog data under power loss or kernel panic.
/// Typical loss stays far smaller because of kernel writeback, but that is
/// not a guarantee.
/// - Process crash or clean stop loses nothing committed, at any threshold.
/// Consistency is never at risk: recovery replays the longest valid WAL
/// prefix atomically.
@@ -325,22 +307,20 @@ test "fingerprint matches a fresh hash of the DDL" {
try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
}
test "ddl creates the query-log tables, the upstream-history tables and every index" {
test "ddl creates the query-log tables and every index" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(ddl);
try testing.expectEqual(
@as(i64, 5),
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
const objects = [_][]const u8{
"domains", "query_log",
"idx_query_log_ts", "idx_query_log_client",
"idx_query_log_domain", "upstream_targets",
"upstream_minute", "idx_upstream_minute_ts",
"querylog_meta",
"idx_query_log_domain", "querylog_meta",
};
for (objects) |name| {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
+18
View File
@@ -172,6 +172,24 @@ pub fn resolveActiveByKey(
return database.changes() != 0;
}
const resolve_by_code_sql =
\\UPDATE operational_events SET resolved_at = ?2
\\ WHERE code = ?1 AND resolved_at IS NULL
;
/// Closes every open episode of `code`, and returns how many. The store uses it
/// once at init for a code no emitter writes any more: an episode whose producer
/// no longer exists can never demonstrate recovery, so nothing but this would
/// ever close it.
pub fn resolveActiveByCode(database: *db.Db, now_s: i64, code: []const u8) db.Error!i64 {
var stmt = try database.prepare(resolve_by_code_sql);
defer stmt.deinit();
try stmt.bindText(1, code);
try stmt.bindInt(2, now_s);
try stmt.exec();
return database.changes();
}
const select_active_id_sql =
"SELECT id FROM operational_events WHERE code = ?1 AND subject_key = ?2 AND resolved_at IS NULL";
+541 -8
View File
@@ -547,7 +547,6 @@ fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8
pub const StatsTotals = struct {
queries: u64,
blocked: u64,
cached: u64,
distinct_clients: u64,
avg_response_time_us: ?i64,
};
@@ -557,7 +556,6 @@ pub const StatsTotals = struct {
const stats_totals_sql =
\\SELECT count(*),
\\ coalesce(sum(blocked <> 0), 0),
\\ coalesce(sum(cache_hit = 1), 0),
\\ count(DISTINCT client_ip),
\\ coalesce(sum(response_time_us), 0),
\\ count(response_time_us)
@@ -577,13 +575,12 @@ pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotal
// statement is not the one this function prepared.
if (!try stmt.step()) return error.Misuse;
const timed = stmt.columnInt(5);
const timed = stmt.columnInt(4);
return .{
.queries = try countOf(stmt.columnInt(0)),
.blocked = try countOf(stmt.columnInt(1)),
.cached = try countOf(stmt.columnInt(2)),
.distinct_clients = try countOf(stmt.columnInt(3)),
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed),
.distinct_clients = try countOf(stmt.columnInt(2)),
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(3), timed),
};
}
@@ -653,10 +650,217 @@ pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Buck
return out.len;
}
/// One row of `/api/stats/types`. `qtype` is nullable in the schema, so the
/// rows that carry no type group into a row of their own rather than
/// disappearing from a breakdown that claims to add up.
///
/// No name field: the only qtype-name table lives in the admin, and a second
/// copy here would drift out of agreement with it.
pub const TypeCount = struct {
qtype: ?u16,
count: u64,
};
/// `qtype IS NULL` sorts 0 before 1, which puts the null row last within a tie.
/// The ordering is total, so two reads of one window return the same list in
/// the same order — which is what makes the goldens byte-stable. It is an
/// order, not an identity: a caller keys on the `qtype` value, never on a row's
/// position, because a rank change between refreshes moves rows and must not
/// move what they mean.
const stats_types_sql =
\\SELECT qtype, count(*)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
\\ GROUP BY qtype
\\ ORDER BY count(*) DESC, qtype IS NULL, qtype ASC
;
/// The query-type breakdown of `[since, until)`. No zero rows: a type absent
/// from the window is absent from the list.
pub fn statsTypes(
database: *db.Db,
arena: Allocator,
since: i64,
until: i64,
) db.Error!std.ArrayList(TypeCount) {
var out: std.ArrayList(TypeCount) = .empty;
var stmt = try database.prepare(stats_types_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
while (try stmt.step()) {
try out.append(arena, .{
.qtype = if (stmt.isNull(0)) null else try columnU16(&stmt, 0),
.count = try countOf(stmt.columnInt(1)),
});
}
return out;
}
/// One row of `/api/stats/routes`: how a slice of the window was answered.
///
/// `source` is the answering resolver's identity and nothing else — the
/// upstream url for `upstream` rows, the zone for `forward_zone` rows, null
/// everywhere else. It is deliberately not `source_name`, which names the
/// blocklist a block came from and would read as an upstream here.
pub const RouteCount = struct {
route: provenance.RouteKind,
source: ?[]const u8,
count: u64,
};
/// A null upstream on an `upstream` row is its own group, not a dropped row: it
/// is a real state of the log and the caller labels it.
const stats_routes_sql =
\\SELECT route_kind,
\\ CASE route_kind
\\ WHEN 'upstream' THEN upstream
\\ WHEN 'forward_zone' THEN forward_zone
\\ END AS source,
\\ count(*)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
\\ GROUP BY route_kind, source
\\ ORDER BY count(*) DESC, route_kind ASC, source IS NULL, source ASC
;
/// The answering-route breakdown of `[since, until)`. Strings are copied into
/// `arena`, which outlives the statement.
pub fn statsRoutes(
database: *db.Db,
arena: Allocator,
since: i64,
until: i64,
) db.Error!std.ArrayList(RouteCount) {
var out: std.ArrayList(RouteCount) = .empty;
var stmt = try database.prepare(stats_routes_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
while (try stmt.step()) {
try out.append(arena, .{
.route = try provenance.parse(provenance.RouteKind, stmt.columnText(0)),
.source = try stmt.columnTextAllocOrNull(arena, 1),
.count = try countOf(stmt.columnInt(2)),
});
}
return out;
}
/// How many clients `/api/stats/clients` names before the rest become `other`.
/// Eight is what one legend can carry without becoming a second table.
pub const max_client_series = 8;
/// One named client's series. `buckets` is always the caller's bucket count
/// long, zero-filled, and aligned exactly like `timeseries`.
pub const ClientSeries = struct {
client: []const u8,
buckets: []const u64,
};
/// `other` is always present and always bucket-count sized — including for an
/// empty window and for a window with eight clients or fewer. A caller charting
/// a stack must not have to invent the residual series.
pub const ClientsBreakdown = struct {
clients: []const ClientSeries,
other: []const u64,
};
/// Ranked by in-window total, ties broken by address, so the cut at eight is
/// the same cut on every request over the same data.
const stats_clients_rank_sql =
\\SELECT client_ip
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
\\ GROUP BY client_ip
\\ ORDER BY count(*) DESC, client_ip ASC
\\ LIMIT ?3
;
const stats_clients_buckets_sql =
\\SELECT client_ip, (timestamp - ?1) / ?2, count(*)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?3
\\ GROUP BY 1, 2
;
/// Per-client counts over `bucket_count` buckets of `bucket_seconds` starting
/// at `since`. Everything outside the top `max_client_series` sums into
/// `other`, so the series still add up to the window's total.
///
/// Two statements, one ranking and one bucketing: the caller runs them inside
/// one read transaction, so the rank and the buckets describe one state.
pub fn statsClients(
database: *db.Db,
arena: Allocator,
since: i64,
bucket_seconds: u32,
bucket_count: u32,
) db.Error!ClientsBreakdown {
if (bucket_seconds == 0 or bucket_count == 0) return error.Misuse;
const width: i64 = bucket_seconds;
const span = std.math.mul(i64, width, bucket_count) catch return error.Misuse;
const until = std.math.add(i64, since, span) catch return error.Misuse;
var names: std.ArrayList([]const u8) = .empty;
var series: std.ArrayList([]u64) = .empty;
{
var stmt = try database.prepare(stats_clients_rank_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
try stmt.bindInt(3, max_client_series);
while (try stmt.step()) {
try names.append(arena, try stmt.columnTextAlloc(arena, 0));
try series.append(arena, try zeroedBuckets(arena, bucket_count));
}
}
const other = try zeroedBuckets(arena, bucket_count);
var stmt = try database.prepare(stats_clients_buckets_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, width);
try stmt.bindInt(3, until);
while (try stmt.step()) {
// The WHERE clause bounds the index already; the check keeps a schema
// surprise from writing past the slice.
const index = std.math.cast(usize, stmt.columnInt(1)) orelse return error.Mismatch;
if (index >= bucket_count) return error.Mismatch;
const count = try countOf(stmt.columnInt(2));
const client = stmt.columnText(0);
const target = for (names.items, series.items) |name_, buckets| {
if (std.mem.eql(u8, name_, client)) break buckets;
} else other;
target[index] += count;
}
const clients = try arena.alloc(ClientSeries, names.items.len);
for (clients, names.items, series.items) |*entry, name_, buckets| {
entry.* = .{ .client = name_, .buckets = buckets };
}
return .{ .clients = clients, .other = other };
}
fn zeroedBuckets(arena: Allocator, bucket_count: u32) Allocator.Error![]u64 {
const buckets = try arena.alloc(u64, bucket_count);
@memset(buckets, 0);
return buckets;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const logger = @import("../logger.zig");
const querylog_schema = @import("../querylog_schema.zig");
const testing = std.testing;
@@ -1469,7 +1673,6 @@ test "statsTotals aggregates the window and averages only the timed rows" {
const totals = try statsTotals(&database, 100, 200);
try testing.expectEqual(@as(u64, 3), totals.queries);
try testing.expectEqual(@as(u64, 1), totals.blocked);
try testing.expectEqual(@as(u64, 1), totals.cached);
try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
// (100 + 200) / 2 — the untimed row is not in the divisor.
try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
@@ -1484,7 +1687,6 @@ test "statsTotals over an empty window is zeros with a null average" {
const totals = try statsTotals(&database, window[0], window[1]);
try testing.expectEqual(@as(u64, 0), totals.queries);
try testing.expectEqual(@as(u64, 0), totals.blocked);
try testing.expectEqual(@as(u64, 0), totals.cached);
try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
}
@@ -1588,3 +1790,334 @@ test "likePattern wraps the needle and neutralises every metacharacter" {
try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b"));
try testing.expectEqualStrings("%%", try likePattern(arena, ""));
}
// ---------------------------------------------------------------------------
// period aggregations (milestone 30)
// ---------------------------------------------------------------------------
const agg_since: i64 = 1_700_000_000;
const agg_width: u32 = 60;
const agg_buckets: u32 = 10;
const agg_until: i64 = agg_since + agg_width * agg_buckets;
/// One row of the aggregation fixtures. Everything the three breakdowns read
/// is a parameter; everything else is the same on every row, so a test that
/// changes an outcome names the reason it changed.
fn aggRow(offset: i64, client: []const u8, qtype: ?u16, kind: provenance.RouteKind, source: ?[]const u8) Row {
return .{
.timestamp = agg_since + offset,
.domain = "example.com",
.client_ip = client,
.qtype = qtype,
.qclass = 1,
.rcode = 0,
.blocked = kind == .blocked,
.response_time_us = 1000,
.cache_hit = kind == .cache,
.upstream = if (kind == .upstream) source else null,
.group_id = 1,
.group_name = "default",
.policy_action = if (kind == .blocked) .block else .allow,
.policy_reason = if (kind == .blocked) .blocklist_domain else .no_match,
.matched = null,
.source_id = null,
// Blocklist provenance, deliberately set on every row: the routes
// breakdown must never group by it.
.source_name = "StevenBlack",
.cname_target = null,
.safe_search_target = null,
.route_kind = kind,
.forward_zone = if (kind == .forward_zone) source else null,
};
}
test "the type breakdown groups by qtype, keeps the null row and orders it last" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{
aggRow(0, "192.0.2.10", 1, .upstream, "9.9.9.9"),
aggRow(1, "192.0.2.10", 1, .upstream, "9.9.9.9"),
aggRow(2, "192.0.2.10", 1, .upstream, "9.9.9.9"),
aggRow(3, "192.0.2.10", 28, .upstream, "9.9.9.9"),
aggRow(4, "192.0.2.10", 28, .upstream, "9.9.9.9"),
// Ties with qtype 28, so the tie-break puts the lower code first.
aggRow(5, "192.0.2.10", 16, .upstream, "9.9.9.9"),
aggRow(6, "192.0.2.10", 16, .upstream, "9.9.9.9"),
// A row with no type at all: its own group, never a dropped row.
aggRow(7, "192.0.2.10", null, .upstream, "9.9.9.9"),
aggRow(8, "192.0.2.10", null, .upstream, "9.9.9.9"),
// Outside the window.
aggRow(-1, "192.0.2.10", 255, .upstream, "9.9.9.9"),
});
const rows = (try statsTypes(&database, arena, agg_since, agg_until)).items;
try testing.expectEqual(@as(usize, 4), rows.len);
try testing.expectEqual(@as(?u16, 1), rows[0].qtype);
try testing.expectEqual(@as(u64, 3), rows[0].count);
try testing.expectEqual(@as(?u16, 16), rows[1].qtype);
try testing.expectEqual(@as(?u16, 28), rows[2].qtype);
try testing.expectEqual(@as(?u16, null), rows[3].qtype);
try testing.expectEqual(@as(u64, 2), rows[3].count);
}
test "an empty window has no type rows at all" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const rows = (try statsTypes(&database, arena_state.allocator(), agg_since, agg_until)).items;
try testing.expectEqual(@as(usize, 0), rows.len);
}
test "the route breakdown keys on the answering resolver, not on blocklist provenance" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{
aggRow(0, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"),
aggRow(1, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"),
aggRow(2, "192.0.2.10", 1, .upstream, "https://b.example/dns-query"),
// An upstream row whose resolver the log did not record: its own group.
aggRow(3, "192.0.2.10", 1, .upstream, null),
aggRow(4, "192.0.2.10", 1, .forward_zone, "lan"),
aggRow(5, "192.0.2.10", 1, .blocked, null),
aggRow(6, "192.0.2.10", 1, .cache, null),
aggRow(7, "192.0.2.10", 1, .local, null),
aggRow(8, "192.0.2.10", 1, .rejected, null),
});
const rows = (try statsRoutes(&database, arena, agg_since, agg_until)).items;
// Two upstreams, one null-source upstream, one forward zone and four
// source-less kinds. Every row carries the same `source_name`, so a
// breakdown that grouped by it would collapse to one row.
try testing.expectEqual(@as(usize, 8), rows.len);
try testing.expectEqual(provenance.RouteKind.upstream, rows[0].route);
try testing.expectEqualStrings("https://a.example/dns-query", rows[0].source.?);
try testing.expectEqual(@as(u64, 2), rows[0].count);
// The seven remaining rows all count 1, so the tie-break orders them:
// route ascending, then source ascending with nulls last.
for (rows[1..]) |row| try testing.expectEqual(@as(u64, 1), row.count);
try testing.expectEqual(provenance.RouteKind.blocked, rows[1].route);
try testing.expectEqual(@as(?[]const u8, null), rows[1].source);
try testing.expectEqual(provenance.RouteKind.cache, rows[2].route);
try testing.expectEqual(provenance.RouteKind.forward_zone, rows[3].route);
try testing.expectEqualStrings("lan", rows[3].source.?);
try testing.expectEqual(provenance.RouteKind.local, rows[4].route);
try testing.expectEqual(provenance.RouteKind.rejected, rows[5].route);
try testing.expectEqual(provenance.RouteKind.upstream, rows[6].route);
try testing.expectEqualStrings("https://b.example/dns-query", rows[6].source.?);
try testing.expectEqual(provenance.RouteKind.upstream, rows[7].route);
try testing.expectEqual(@as(?[]const u8, null), rows[7].source);
}
test "an empty window has no route rows at all" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const rows = (try statsRoutes(&database, arena_state.allocator(), agg_since, agg_until)).items;
try testing.expectEqual(@as(usize, 0), rows.len);
}
test "an empty window still has a zero-filled other series and no named clients" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, 0), result.clients.len);
try testing.expectEqual(@as(usize, agg_buckets), result.other.len);
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
}
test "client series are bucket-aligned, zero-filled and ranked by in-window total" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try seed(&database, &.{
aggRow(0, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(1, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(agg_width * 3, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(agg_width * 3, "192.0.2.10", 1, .upstream, "9.9.9.9"),
// Outside the window on both sides.
aggRow(-1, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(agg_width * agg_buckets, "192.0.2.10", 1, .upstream, "9.9.9.9"),
});
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, 2), result.clients.len);
try testing.expectEqualStrings("192.0.2.20", result.clients[0].client);
try testing.expectEqualStrings("192.0.2.10", result.clients[1].client);
for (result.clients) |series| try testing.expectEqual(@as(usize, agg_buckets), series.buckets.len);
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
try testing.expectEqual(@as(u64, 1), result.clients[0].buckets[3]);
try testing.expectEqual(@as(u64, 0), result.clients[0].buckets[9]);
try testing.expectEqual(@as(u64, 1), result.clients[1].buckets[3]);
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
}
test "the ninth client folds into other and the cut is the same on every read" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Nine clients, each with one more query than the next, so the ranking is
// total and the ninth is unambiguously the one that folds.
var address: [16]u8 = undefined;
var index: u32 = 0;
while (index < 9) : (index += 1) {
const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index});
var repeat: u32 = 0;
while (repeat <= index) : (repeat += 1) {
try seed(&database, &.{aggRow(@intCast(repeat), client, 1, .upstream, "9.9.9.9")});
}
}
const result = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, max_client_series), result.clients.len);
// The busiest is 192.0.2.108 with nine rows; the lone folded client is
// 192.0.2.100 with one.
try testing.expectEqualStrings("192.0.2.108", result.clients[0].client);
for (result.clients) |series| {
try testing.expect(!std.mem.eql(u8, "192.0.2.100", series.client));
}
var other_total: u64 = 0;
for (result.other) |count| other_total += count;
try testing.expectEqual(@as(u64, 1), other_total);
}
test "the three breakdowns conserve the window's total" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A matrix that exercises every branch the aggregations partition on: null
// and non-null qtypes, every route kind, a null resolver identity, and ten
// clients so the top-eight cut has a residual to carry.
const kinds = [_]provenance.RouteKind{ .blocked, .local, .forward_zone, .upstream, .cache, .rejected };
var address: [16]u8 = undefined;
var index: u32 = 0;
while (index < 40) : (index += 1) {
const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index % 10});
const kind = kinds[index % kinds.len];
try seed(&database, &.{aggRow(
@intCast(index % (agg_width * agg_buckets)),
client,
if (index % 7 == 0) null else @intCast(1 + index % 3),
kind,
if (index % 11 == 0) null else "9.9.9.9",
)});
}
const totals = try statsTotals(&database, agg_since, agg_until);
try testing.expect(totals.queries > 0);
var typed: u64 = 0;
for ((try statsTypes(&database, arena, agg_since, agg_until)).items) |row| typed += row.count;
try testing.expectEqual(totals.queries, typed);
var routed: u64 = 0;
for ((try statsRoutes(&database, arena, agg_since, agg_until)).items) |row| routed += row.count;
try testing.expectEqual(totals.queries, routed);
var buckets: [agg_buckets]Bucket = undefined;
_ = try timeseries(&database, agg_since, agg_width, &buckets);
const clients = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
// Per bucket, not just in total: a series misaligned by one bucket would
// still sum correctly over the window.
for (buckets, 0..) |bucket, at| {
var summed: u64 = clients.other[at];
for (clients.clients) |series| summed += series.buckets[at];
try testing.expectEqual(bucket.queries, summed);
}
}
test "the aggregations pass a redacted client through as the log stored it" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// `hide_client_ips` is applied by the logger before the row is written, so
// the read path has nothing to transform — and must not invent anything
// either. The marker is the client's whole identity here.
try seed(&database, &.{
aggRow(0, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
aggRow(1, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
});
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, 1), result.clients.len);
try testing.expectEqualStrings(logger.hidden_marker, result.clients[0].client);
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
}
test "a prune committed mid-read is invisible to the reader's transaction" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/querylog.db", .{ tmp_prefix, &tmp.sub_path });
var reader = try db.Db.open(path, .{ .mode = .read_write_create });
defer reader.close();
try db.applyPragmas(&reader, .{});
try reader.exec(querylog_schema.ddl);
// The seeded watermark is the file's creation second, which is now; the
// fixtures below are in 2023, so the prune's cutoff would never advance it.
try reader.exec(
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
);
try seed(&reader, &.{
plainRow(agg_since, "a.example"),
plainRow(agg_since + 1, "b.example"),
plainRow(agg_since + 300, "c.example"),
});
// A second connection to the same file, as retention has in production.
var pruner = try db.Db.open(path, .{ .mode = .read_write_create });
defer pruner.close();
try db.applyPragmas(&pruner, .{});
var tx = try db.ReadTx.begin(&reader);
const before = try statsTotals(&reader, agg_since, agg_since + 1000);
const watermark_before = try availableSince(&reader);
try testing.expectEqual(@as(u64, 3), before.queries);
// The prune commits while the reader's transaction is open.
const pruned = try pruneOlderThan(&pruner, agg_since + 200);
try testing.expectEqual(@as(i64, 2), pruned.deleted);
try testing.expect(pruned.available_since > watermark_before);
// Neither half of the answer moved: the rows the reader would report and
// the watermark it would tag them with still describe one state.
const during = try statsTotals(&reader, agg_since, agg_since + 1000);
try testing.expectEqual(before.queries, during.queries);
try testing.expectEqual(watermark_before, try availableSince(&reader));
try tx.commit();
// The next response sees the prune — both halves of it.
const after = try statsTotals(&reader, agg_since, agg_since + 1000);
try testing.expectEqual(@as(u64, 1), after.queries);
try testing.expectEqual(pruned.available_since, try availableSince(&reader));
}
@@ -1,493 +0,0 @@
//! `upstream_minute` and its `upstream_targets` dimension table in
//! `querylog.db` (milestone-26 rulings 2, 4, 5).
//!
//! One row per upstream per wall-clock UTC minute that had at least one
//! attempt. Rows are additive facts: a flush adds to whatever is already there,
//! so a restart inside a minute continues that minute's row rather than
//! replacing it, and nothing here can lower a stored count.
//!
//! Identity is the url, not the `config.db` upstream id: ids cannot be foreign
//! keys across database files and may be deleted or reused. Editing an
//! upstream's url deliberately starts a new history.
//!
//! Nothing here retries. The accumulator owns what a failed flush means
//! (`upstream/history.zig`).
const std = @import("std");
const db = @import("../db.zig");
const health = @import("../../upstream/health.zig");
/// How far back `Retention` keeps minute rows. A fixed window, not a knob
/// (m26 anti-requirements), and deliberately wider than the widest dashboard
/// period: `stats.window` derives `since = until - width * count` with
/// `until > now`, so a 30-day window never asks for anything older than
/// `now - 30d`. The extra day is slack for a retention pass that runs late.
///
/// `logging.retention_days` does not apply here. It bounds the query log, whose
/// rows are per-query; these are per-minute aggregates whose whole purpose is
/// to outlive them.
pub const retention_window_s: i64 = 31 * 86_400;
/// One minute of one upstream's outcomes, as the accumulator hands it over.
/// Every string is borrowed for the duration of the call: `Stmt.bindText` binds
/// with `SQLITE_TRANSIENT`, so SQLite copies before `flush` returns.
pub const FlushRow = struct {
url: []const u8,
minute_ts: i64,
successes: u32,
failures: u32,
last_failure_ts: ?i64,
/// Empty when the minute held no failure.
last_error: []const u8,
};
const insert_target_sql = "INSERT OR IGNORE INTO upstream_targets (url) VALUES (?1)";
const select_target_sql = "SELECT id FROM upstream_targets WHERE url = ?1";
/// Additive, and the timestamp columns are max-wins, which is what makes a
/// flush safe to repeat against a row another process already wrote.
///
/// `max()` over a NULL is NULL in SQLite, so the coalesce is what keeps an
/// existing `last_failure_ts` when the incoming row carries none. The `CASE`
/// moves `last_error` with the timestamp it belongs to: a success-only upsert
/// leaves the stored failure and its name exactly as they were.
const upsert_minute_sql =
\\INSERT INTO upstream_minute (upstream_id, minute_ts, successes, failures, last_failure_ts, last_error)
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6)
\\ON CONFLICT(upstream_id, minute_ts) DO UPDATE SET
\\ successes = successes + excluded.successes,
\\ failures = failures + excluded.failures,
\\ last_failure_ts = coalesce(max(last_failure_ts, excluded.last_failure_ts), last_failure_ts, excluded.last_failure_ts),
\\ last_error = CASE
\\ WHEN excluded.last_failure_ts IS NOT NULL
\\ AND (last_failure_ts IS NULL OR excluded.last_failure_ts >= last_failure_ts)
\\ THEN excluded.last_error
\\ ELSE last_error
\\ END
;
/// One transaction for the whole batch: either every minute of the pass lands
/// or none of it does, so a failed flush leaves nothing half-written for the
/// caller's merge-back to double-count.
pub fn flush(database: *db.Db, rows: []const FlushRow) db.Error!void {
if (rows.len == 0) return;
var insert_target = try database.prepare(insert_target_sql);
defer insert_target.deinit();
var select_target = try database.prepare(select_target_sql);
defer select_target.deinit();
var upsert = try database.prepare(upsert_minute_sql);
defer upsert.deinit();
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
for (rows) |row| {
const upstream_id = try internTarget(&insert_target, &select_target, row.url);
try upsert.reset();
try upsert.bindInt(1, upstream_id);
try upsert.bindInt(2, row.minute_ts);
try upsert.bindInt(3, row.successes);
try upsert.bindInt(4, row.failures);
if (row.last_failure_ts) |at| try upsert.bindInt(5, at) else try upsert.bindNull(5);
try upsert.bindText(6, row.last_error);
try upsert.exec();
}
try tx.commit();
}
fn internTarget(insert: *db.Stmt, select: *db.Stmt, url: []const u8) db.Error!i64 {
try insert.reset();
try insert.bindText(1, url);
try insert.exec();
try select.reset();
try select.bindText(1, url);
// The insert above either created the row or found it already there, so a
// miss means the table changed under this connection.
if (!try select.step()) return error.NotFound;
const id = select.columnInt(0);
// A statement stopped on a row keeps its cursor open until it is reset;
// the transaction must not carry that to the next row.
try select.reset();
return id;
}
/// What `GET /api/upstream/health` reports for one upstream over one window.
pub const WindowStats = struct {
attempts: u64,
successes: u64,
failures: u64,
last_failure_ts: ?i64,
/// The error name of the row holding the newest `last_failure_ts` in the
/// window; empty when the window holds no failure.
///
/// By value rather than by slice: the caller loops over upstreams and
/// reuses one `WindowStats`, so a borrowed slice would dangle into the
/// storage the next iteration overwrites.
last_failure_error_buf: [health.error_name_capacity]u8,
last_failure_error_len: u8,
pub fn lastFailureError(self: *const WindowStats) []const u8 {
return self.last_failure_error_buf[0..self.last_failure_error_len];
}
};
/// **One statement, deliberately.** Two statements would not share a SQLite
/// snapshot: the flush connection can commit between them, and the read would
/// then pair a `max(last_failure_ts)` taken from one state with an error text
/// taken from another.
///
/// The error lookup is a scalar subquery for the same reason it is not a bare
/// column: `SELECT max(last_failure_ts), last_error` lets SQLite return the
/// `last_error` of an arbitrary row of the group. `ORDER BY ... DESC, minute_ts
/// DESC` makes the choice deterministic when two minutes share a timestamp.
const window_stats_sql =
\\SELECT coalesce(sum(m.successes), 0), coalesce(sum(m.failures), 0), max(m.last_failure_ts),
\\ (SELECT e.last_error FROM upstream_minute e
\\ WHERE e.upstream_id = m.upstream_id AND e.minute_ts >= ?2 AND e.minute_ts < ?3
\\ AND e.last_failure_ts IS NOT NULL
\\ ORDER BY e.last_failure_ts DESC, e.minute_ts DESC LIMIT 1)
\\ FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
\\ WHERE t.url = ?1 AND m.minute_ts >= ?2 AND m.minute_ts < ?3
;
/// Aggregates `[since, until)` by `minute_ts`. An unknown url or an empty
/// window is zeros, a null timestamp and the empty error — not an error.
pub fn windowStats(database: *db.Db, url: []const u8, since: i64, until: i64) db.Error!WindowStats {
var stmt = try database.prepare(window_stats_sql);
defer stmt.deinit();
try stmt.bindText(1, url);
try stmt.bindInt(2, since);
try stmt.bindInt(3, until);
// A bare aggregate always produces exactly one row; no row means the
// statement is not the one this function prepared.
if (!try stmt.step()) return error.Misuse;
const successes = try countOf(stmt.columnInt(0));
const failures = try countOf(stmt.columnInt(1));
var out: WindowStats = .{
.attempts = successes + failures,
.successes = successes,
.failures = failures,
.last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
.last_failure_error_buf = @splat(0),
.last_failure_error_len = 0,
};
const name = stmt.columnText(3);
const copied = @min(name.len, out.last_failure_error_buf.len);
@memcpy(out.last_failure_error_buf[0..copied], name[0..copied]);
out.last_failure_error_len = @intCast(copied);
return out;
}
/// `sum` over `CHECK (… >= 0)` columns cannot go negative; a negative value
/// means the row came from something other than this schema.
fn countOf(value: i64) db.Error!u64 {
if (value < 0) return error.Mismatch;
return @intCast(value);
}
/// Deletes every `upstream_minute` row strictly older than `cutoff_ts`, then
/// the targets no surviving row references, and returns how many **minute**
/// rows went.
///
/// One transaction: a target dropped without its rows, or rows dropped while
/// the target delete failed, would leave the foreign key pointing at nothing.
/// The count is minute rows only, so the metric an operator watches counts
/// aggregates rather than dimension-table housekeeping.
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
var minutes = try database.prepare("DELETE FROM upstream_minute WHERE minute_ts < ?1");
defer minutes.deinit();
try minutes.bindInt(1, cutoff_ts);
try minutes.exec();
const deleted = database.changes();
try database.exec(
\\DELETE FROM upstream_targets
\\ WHERE id NOT IN (SELECT upstream_id FROM upstream_minute);
);
try tx.commit();
return deleted;
}
pub fn countMinutes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstream_minute");
}
pub fn countTargets(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstream_targets");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const querylog_schema = @import("../querylog_schema.zig");
const testing = std.testing;
fn openLog() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
/// `columnText` is borrowed until the statement is finalized, so the stored
/// error name is copied out rather than returned as a slice.
const StoredMinute = struct {
successes: u32,
failures: u32,
last_failure_ts: ?i64,
error_buf: [health.error_name_capacity]u8,
error_len: u8,
fn lastError(self: *const StoredMinute) []const u8 {
return self.error_buf[0..self.error_len];
}
};
fn readMinute(database: *db.Db, url: []const u8, minute_ts: i64) !StoredMinute {
var stmt = try database.prepare(
\\SELECT m.successes, m.failures, m.last_failure_ts, m.last_error
\\ FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
\\ WHERE t.url = ?1 AND m.minute_ts = ?2
);
defer stmt.deinit();
try stmt.bindText(1, url);
try stmt.bindInt(2, minute_ts);
try testing.expect(try stmt.step());
var out: StoredMinute = .{
.successes = @intCast(stmt.columnInt(0)),
.failures = @intCast(stmt.columnInt(1)),
.last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
.error_buf = @splat(0),
.error_len = 0,
};
const name = stmt.columnText(3);
@memcpy(out.error_buf[0..name.len], name);
out.error_len = @intCast(name.len);
return out;
}
test "the upsert adds to the row already there rather than replacing it" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 3, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
});
// The shape a restart inside one minute takes: a second process writes the
// same (url, minute) and the counts continue.
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 4, .last_failure_ts = 90, .last_error = "ConnectFailed" },
});
const row = try readMinute(&database, "https://a.example", 60);
try testing.expectEqual(@as(u32, 5), row.successes);
try testing.expectEqual(@as(u32, 5), row.failures);
try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
try testing.expectEqualStrings("ConnectFailed", row.lastError());
// One row and one target, not two of either.
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
}
test "a success-only upsert keeps the failure timestamp and its error" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
});
// `max()` over a NULL is NULL in SQLite, so without the coalesce this
// upsert would erase the timestamp it knows nothing about.
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 5, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
const row = try readMinute(&database, "https://a.example", 60);
try testing.expectEqual(@as(u32, 5), row.successes);
try testing.expectEqual(@as(u32, 1), row.failures);
try testing.expectEqual(@as(?i64, 70), row.last_failure_ts);
try testing.expectEqualStrings("Timeout", row.lastError());
}
test "an older failure does not overwrite the newer error already stored" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 90, .last_error = "Timeout" },
});
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "ConnectFailed" },
});
const row = try readMinute(&database, "https://a.example", 60);
try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
try testing.expectEqualStrings("Timeout", row.lastError());
}
test "flush interns each url once and writes every minute of the batch" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://a.example", .minute_ts = 120, .successes = 2, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://b.example", .minute_ts = 60, .successes = 3, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
try testing.expectEqual(@as(i64, 3), try countMinutes(&database));
try testing.expectEqual(@as(i64, 2), try countTargets(&database));
// An empty batch opens no transaction: one is already open here, so a
// `BEGIN IMMEDIATE` would fail.
var tx = try db.Tx.begin(&database);
try flush(&database, &.{});
tx.rollback();
}
test "windowStats sums only the window and pairs the newest failure with its own error" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
// Before the window.
.{ .url = "https://a.example", .minute_ts = 0, .successes = 9, .failures = 9, .last_failure_ts = 30, .last_error = "Outside" },
.{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 1, .last_failure_ts = 100, .last_error = "ConnectFailed" },
.{ .url = "https://a.example", .minute_ts = 120, .successes = 4, .failures = 2, .last_failure_ts = 170, .last_error = "Timeout" },
// A later minute with no failure at all: the error must still come from
// the minute holding the newest `last_failure_ts`, not from this one.
.{ .url = "https://a.example", .minute_ts = 180, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
// At the exclusive end of the window.
.{ .url = "https://a.example", .minute_ts = 240, .successes = 7, .failures = 7, .last_failure_ts = 250, .last_error = "After" },
// A different upstream in the same minutes.
.{ .url = "https://b.example", .minute_ts = 120, .successes = 5, .failures = 5, .last_failure_ts = 175, .last_error = "Other" },
});
const stats = try windowStats(&database, "https://a.example", 60, 240);
try testing.expectEqual(@as(u64, 7), stats.successes);
try testing.expectEqual(@as(u64, 3), stats.failures);
try testing.expectEqual(@as(u64, 10), stats.attempts);
try testing.expectEqual(@as(?i64, 170), stats.last_failure_ts);
try testing.expectEqualStrings("Timeout", stats.lastFailureError());
}
test "two minutes sharing the newest failure timestamp resolve to the later minute" {
// The tiebreak the subquery's ORDER BY owns. `max(last_failure_ts)` alone
// cannot choose between these two rows, so without a deterministic second
// key the answer is whichever row SQLite happened to visit.
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Earlier" },
.{ .url = "https://a.example", .minute_ts = 120, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Later" },
});
const stats = try windowStats(&database, "https://a.example", 0, 1000);
try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
try testing.expectEqualStrings("Later", stats.lastFailureError());
}
test "windowStats over an unknown url or an empty window is zeros and no error" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
});
for ([_][3]i64{ .{ 0, 60, 0 }, .{ 120, 180, 0 }, .{ 60, 60, 0 } }) |window| {
const stats = try windowStats(&database, "https://a.example", window[0], window[1]);
try testing.expectEqual(@as(u64, 0), stats.attempts);
try testing.expectEqual(@as(u64, 0), stats.successes);
try testing.expectEqual(@as(u64, 0), stats.failures);
try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
try testing.expectEqualStrings("", stats.lastFailureError());
}
const unknown = try windowStats(&database, "https://never.example", 0, 1000);
try testing.expectEqual(@as(u64, 0), unknown.attempts);
try testing.expectEqual(@as(?i64, null), unknown.last_failure_ts);
try testing.expectEqualStrings("", unknown.lastFailureError());
}
test "a window whose only failures are outside it reports no failure at all" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 0, .successes = 0, .failures = 1, .last_failure_ts = 30, .last_error = "Timeout" },
.{ .url = "https://a.example", .minute_ts = 60, .successes = 4, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
const stats = try windowStats(&database, "https://a.example", 60, 120);
try testing.expectEqual(@as(u64, 4), stats.attempts);
try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
try testing.expectEqualStrings("", stats.lastFailureError());
}
test "pruneOlderThan counts minute rows only and drops the orphaned target" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://old.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://old.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://kept.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://kept.example", .minute_ts = 300, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
// Three minute rows go; the two target deletes must not join the count.
try testing.expectEqual(@as(i64, 3), try pruneOlderThan(&database, 300));
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
// "old.example" has nothing left, "kept.example" still does.
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
// The row exactly at the cutoff stays, and a second pass finds nothing.
try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 300));
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
}
test "a failed prune leaves both tables as they were" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
try database.exec(
\\CREATE TRIGGER refuse_target_delete BEFORE DELETE ON upstream_targets
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
// The minute delete succeeds and the target delete does not; one
// transaction means neither survives.
try testing.expectError(error.Constraint, pruneOlderThan(&database, 300));
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
}
test "an error name longer than the buffer is truncated, not overflowed" {
var database = try openLog();
defer database.close();
const long = "A" ** 200;
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = long },
});
const stats = try windowStats(&database, "https://a.example", 60, 120);
try testing.expectEqual(@as(usize, health.error_name_capacity), stats.lastFailureError().len);
try testing.expectEqualStrings(long[0..health.error_name_capacity], stats.lastFailureError());
}
+2 -102
View File
@@ -16,7 +16,6 @@ const disk_monitor = @import("disk_monitor.zig");
const events = @import("events.zig");
const model = @import("../config/model.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const upstream_history_repo = @import("repositories/upstream_history_repo.zig");
const log = std.log.scoped(.retention);
@@ -33,10 +32,6 @@ pub const pass_interval_s = 86_400;
pub const Stats = struct {
passes: u64 = 0,
rows_pruned: u64 = 0,
/// Upstream-history minute rows, counted apart from `rows_pruned`: that
/// counter is the query log's, and an operator watching it must not see it
/// move because a different table was tidied.
upstream_rows_pruned: u64 = 0,
checkpoints: u64 = 0,
vacuums: u64 = 0,
/// Vacuums the disk monitor refused. The pass still pruned and
@@ -50,7 +45,6 @@ pub const Stats = struct {
const Counters = struct {
passes: std.atomic.Value(u64) = .init(0),
rows_pruned: std.atomic.Value(u64) = .init(0),
upstream_rows_pruned: std.atomic.Value(u64) = .init(0),
checkpoints: std.atomic.Value(u64) = .init(0),
vacuums: std.atomic.Value(u64) = .init(0),
vacuums_gated: std.atomic.Value(u64) = .init(0),
@@ -74,7 +68,6 @@ pub const Retention = struct {
return .{
.passes = self.counters.passes.load(.monotonic),
.rows_pruned = self.counters.rows_pruned.load(.monotonic),
.upstream_rows_pruned = self.counters.upstream_rows_pruned.load(.monotonic),
.checkpoints = self.counters.checkpoints.load(.monotonic),
.vacuums = self.counters.vacuums.load(.monotonic),
.vacuums_gated = self.counters.vacuums_gated.load(.monotonic),
@@ -124,22 +117,6 @@ pub const Retention = struct {
maintenance(store, io, now, "prune", @errorName(err));
}
// Before the vacuum-cadence logic below, which returns early on six
// passes out of seven and again whenever the monitor refuses the
// vacuum. A step placed after it would almost never run.
//
// `logging.retention_days` is not the window here: these are per-minute
// aggregates whose whole purpose is to outlive the per-query rows, so
// the window is the repository's own constant.
const history_cutoff = now - upstream_history_repo.retention_window_s;
if (upstream_history_repo.pruneOlderThan(database, history_cutoff)) |deleted| {
add(&self.counters.upstream_rows_pruned, @intCast(deleted));
maintenance(store, io, now, "history_prune", null);
} else |err| {
log.warn("upstream history prune before {d} failed: {s}", .{ history_cutoff, @errorName(err) });
maintenance(store, io, now, "history_prune", @errorName(err));
}
if (queries_repo.checkpointTruncate(database)) {
add(&self.counters.checkpoints, 1);
maintenance(store, io, now, "checkpoint", null);
@@ -443,83 +420,6 @@ test "a failing prune counts the pass and leaves the rows alone" {
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
}
test "the upstream-history window is its own, and a one-day query log does not shrink it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
const day = 86_400;
try writeRows(&database, &.{ now - 2 * day, now - 60 });
try upstream_history_repo.flush(&database, &.{
.{ .url = "https://gone.example", .minute_ts = now - 32 * day, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://kept.example", .minute_ts = now - 29 * day, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
// A query log kept for one day, and 30 days of upstream minutes beside it.
var retention: Retention = .init(.{ .retention_days = 1 });
retention.runOnce(io, &database, null, null);
const stats = retention.snapshotStats();
// One query-log row is older than one day; one minute row is older than the
// fixed 31-day upstream window. Each counter moved by its own amount.
try testing.expectEqual(@as(u64, 1), stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), stats.upstream_rows_pruned);
try testing.expectEqual(@as(i64, 1), try upstream_history_repo.countMinutes(&database));
// The day-29 row is exactly what a 30-day dashboard window asks for.
const kept = try upstream_history_repo.windowStats(
&database,
"https://kept.example",
now - 30 * day,
now,
);
try testing.expectEqual(@as(u64, 1), kept.attempts);
// And the emptied target went with its rows.
try testing.expectEqual(@as(i64, 1), try upstream_history_repo.countTargets(&database));
}
test "the history prune runs on the passes where the vacuum logic returns early" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
const old_minute = now - 40 * 86_400;
// The first pass: `passes_since_vacuum` is 1, so the vacuum block returns
// before it does anything. A prune placed after that block would never run
// on six passes out of seven.
try upstream_history_repo.flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = old_minute, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
var early: Retention = .init(.{});
early.runOnce(io, &database, null, null);
try testing.expectEqual(@as(u64, 1), early.snapshotStats().upstream_rows_pruned);
try testing.expectEqual(@as(i64, 0), try upstream_history_repo.countMinutes(&database));
// The gated pass: the disk monitor refuses the vacuum and that branch
// returns too, and the prune still has to have happened before it.
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
var gated: Retention = .init(.{});
for (0..vacuum_every_passes - 1) |_| gated.runOnce(io, &database, &monitor, null);
try upstream_history_repo.flush(&database, &.{
.{ .url = "https://b.example", .minute_ts = old_minute, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
gated.runOnce(io, &database, &monitor, null);
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums_gated);
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().upstream_rows_pruned);
try testing.expectEqual(@as(i64, 0), try upstream_history_repo.countMinutes(&database));
}
test "the next pass retries what the failed one could not do" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -569,8 +469,8 @@ test "a failing prune opens a maintenance episode the next clean pass closes" {
var retention: Retention = .init(.{});
retention.runOnce(io, &database, null, &fx.store);
// Only the prune failed; checkpoint and history prune succeeded, and a
// success writes no row of its own.
// Only the prune failed; the checkpoint succeeded, and a success writes no
// row of its own.
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqualStrings("query_log.maintenance", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings("prune", try fx.text("SELECT subject_key FROM operational_events"));