milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
This commit is contained in:
@@ -98,6 +98,57 @@ pub const ddl_v1: [:0]const u8 =
|
||||
\\);
|
||||
\\
|
||||
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
\\
|
||||
\\CREATE TABLE operational_events (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ code TEXT NOT NULL,
|
||||
\\ subject_key TEXT NOT NULL,
|
||||
\\ subject_label TEXT NOT NULL,
|
||||
\\ severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
|
||||
\\ first_seen INTEGER NOT NULL,
|
||||
\\ last_seen INTEGER NOT NULL,
|
||||
\\ occurrences INTEGER NOT NULL CHECK (occurrences > 0),
|
||||
\\ resolved_at INTEGER,
|
||||
\\ detail TEXT NOT NULL DEFAULT '',
|
||||
\\ CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
|
||||
\\);
|
||||
\\CREATE UNIQUE INDEX idx_operational_events_active
|
||||
\\ ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
|
||||
\\CREATE INDEX idx_operational_events_last_seen
|
||||
\\ ON operational_events(last_seen DESC);
|
||||
;
|
||||
|
||||
/// The same three statements, each made conditional, for the bridge at the end
|
||||
/// of `migrations.migrate`.
|
||||
///
|
||||
/// A database stamped version 1 before `operational_events` joined `ddl_v1`
|
||||
/// never runs step 1 again, so nothing would ever create the table there. The
|
||||
/// bridge closes that divergence for the pre-0.1 installs that exist; it is
|
||||
/// **removable the moment the v0.1 adoption gate lands**, because from then on
|
||||
/// a schema change is an append-only migration step and this hazard cannot
|
||||
/// recur.
|
||||
///
|
||||
/// It must not be folded into `ddl_v1`: a fresh database would then create the
|
||||
/// table twice, and the unconditional `CREATE TABLE` above is what proves the
|
||||
/// baseline and this text stay in step.
|
||||
pub const operational_events_bridge: [:0]const u8 =
|
||||
\\CREATE TABLE IF NOT EXISTS operational_events (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ code TEXT NOT NULL,
|
||||
\\ subject_key TEXT NOT NULL,
|
||||
\\ subject_label TEXT NOT NULL,
|
||||
\\ severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
|
||||
\\ first_seen INTEGER NOT NULL,
|
||||
\\ last_seen INTEGER NOT NULL,
|
||||
\\ occurrences INTEGER NOT NULL CHECK (occurrences > 0),
|
||||
\\ resolved_at INTEGER,
|
||||
\\ detail TEXT NOT NULL DEFAULT '',
|
||||
\\ CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
|
||||
\\);
|
||||
\\CREATE UNIQUE INDEX IF NOT EXISTS idx_operational_events_active
|
||||
\\ ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
|
||||
\\CREATE INDEX IF NOT EXISTS idx_operational_events_last_seen
|
||||
\\ ON operational_events(last_seen DESC);
|
||||
;
|
||||
|
||||
/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
|
||||
@@ -108,7 +159,10 @@ pub const ddl_v1: [:0]const u8 =
|
||||
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
|
||||
/// keys, so their position is free; `groups` and `blocklist_sources` must come
|
||||
/// last, after every referrer. `schema_version` is deliberately absent — an
|
||||
/// import must never erase the stamped migration version.
|
||||
/// import must never erase the stamped migration version — and so is
|
||||
/// `operational_events`, which is runtime state rather than configuration: an
|
||||
/// import that wiped the diagnostics log would destroy the record of what the
|
||||
/// box has been doing.
|
||||
pub const delete_order = [_][]const u8{
|
||||
"group_sources", "rules", "client_prefixes", "clients",
|
||||
"upstreams", "local_records", "forward_zones", "settings",
|
||||
@@ -120,12 +174,16 @@ pub const delete_order = [_][]const u8{
|
||||
/// that renumbered a group.
|
||||
///
|
||||
/// `schema_version` is absent: it is the migration's, not the operator's.
|
||||
/// `operational_events` is absent for the same class of reason: it is the
|
||||
/// program's own record of its failures, and an export of it would be a log
|
||||
/// dump, not a configuration.
|
||||
pub const table_names = [_][]const u8{
|
||||
"groups", "clients", "client_prefixes", "upstreams",
|
||||
"blocklist_sources", "group_sources", "rules", "local_records",
|
||||
"forward_zones", "settings",
|
||||
};
|
||||
|
||||
const db = @import("db.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
test "table_names names exactly the tables delete_order does" {
|
||||
@@ -135,6 +193,48 @@ test "table_names names exactly the tables delete_order does" {
|
||||
}
|
||||
try testing.expect(indexOf(&table_names, "groups") != null);
|
||||
try testing.expect(indexOf(&table_names, "schema_version") == null);
|
||||
// Runtime state, not configuration: neither list may reach it, or an
|
||||
// import would wipe the diagnostics log and an export would emit it.
|
||||
try testing.expect(indexOf(&table_names, "operational_events") == null);
|
||||
try testing.expect(indexOf(&delete_order, "operational_events") == null);
|
||||
}
|
||||
|
||||
test "the bridge creates exactly what the baseline does" {
|
||||
// The two texts are separate on purpose (a fresh database must not create
|
||||
// the table twice), which is exactly how they could drift. Applying each to
|
||||
// its own database and comparing `sqlite_schema` is what keeps them equal.
|
||||
const baseline = try schemaOf(ddl_v1);
|
||||
defer testing.allocator.free(baseline);
|
||||
const bridged = try schemaOf(operational_events_bridge);
|
||||
defer testing.allocator.free(bridged);
|
||||
|
||||
// SQLite stores the `CREATE` text verbatim, so the conditional is the one
|
||||
// difference the two are allowed to have.
|
||||
const size = std.mem.replacementSize(u8, bridged, " IF NOT EXISTS", "");
|
||||
const plain = try testing.allocator.alloc(u8, size);
|
||||
defer testing.allocator.free(plain);
|
||||
_ = std.mem.replace(u8, bridged, " IF NOT EXISTS", "", plain);
|
||||
|
||||
try testing.expectEqualStrings(baseline, plain);
|
||||
}
|
||||
|
||||
/// Every `sqlite_schema` row of `operational_events`, after applying `sql`.
|
||||
fn schemaOf(sql: [:0]const u8) ![]u8 {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try database.exec(sql);
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
errdefer out.deinit();
|
||||
var stmt = try database.prepare(
|
||||
\\SELECT type, name, sql FROM sqlite_schema
|
||||
\\ WHERE tbl_name = 'operational_events' ORDER BY name
|
||||
);
|
||||
defer stmt.deinit();
|
||||
while (try stmt.step()) {
|
||||
try out.writer.print("{s} {s}\n{s}\n", .{ stmt.columnText(0), stmt.columnText(1), stmt.columnText(2) });
|
||||
}
|
||||
return out.toOwnedSlice();
|
||||
}
|
||||
|
||||
test "delete_order lists every referrer before the table it references" {
|
||||
|
||||
+168
-13
@@ -9,6 +9,7 @@
|
||||
//! milestone-5 file; the gate is pulled, not pushed.
|
||||
|
||||
const std = @import("std");
|
||||
const events = @import("events.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const statfs = @import("../platform/statfs.zig");
|
||||
|
||||
@@ -89,43 +90,49 @@ pub const Monitor = struct {
|
||||
/// evidence that the disk filled — and a failed size scan leaves that one
|
||||
/// gauge at its previous reading. Every failure increments
|
||||
/// `sample_failures` and logs one line at `warn`.
|
||||
pub fn sample(self: *Monitor, io: std.Io) void {
|
||||
const free = statfs.freeBytes(self.data_path) catch {
|
||||
pub fn sample(self: *Monitor, io: std.Io, store: ?*events.Store, now_s: i64) void {
|
||||
const free = statfs.freeBytes(self.data_path) catch |err| {
|
||||
self.countFailure();
|
||||
log.warn("statvfs on {s} failed", .{self.data_path});
|
||||
probeFailed(store, io, now_s, "statvfs", "statvfs on the data path failed", err);
|
||||
return;
|
||||
};
|
||||
self.free_bytes.store(free, .monotonic);
|
||||
if (store) |s| s.resolve(io, now_s, .disk_probe, "statvfs");
|
||||
|
||||
if (sumDir(io, self.data_dir, isDatabaseFile)) |bytes| {
|
||||
self.db_bytes.store(bytes, .monotonic);
|
||||
if (store) |s| s.resolve(io, now_s, .disk_probe, "data_dir");
|
||||
} else |err| {
|
||||
self.countFailure();
|
||||
log.warn("sizing the data directory failed: {s}", .{@errorName(err)});
|
||||
probeFailed(store, io, now_s, "data_dir", "sizing the data directory failed", err);
|
||||
}
|
||||
|
||||
if (self.log_dir_path) |path| {
|
||||
if (self.sumLogDir(io, path)) |bytes| {
|
||||
self.log_bytes.store(bytes, .monotonic);
|
||||
if (store) |s| s.resolve(io, now_s, .disk_probe, "log_dir");
|
||||
} else |err| {
|
||||
self.countFailure();
|
||||
log.warn("sizing {s} failed: {s}", .{ path, @errorName(err) });
|
||||
probeFailed(store, io, now_s, "log_dir", "sizing the log directory failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
self.publish(classify(free, self.cfg), free);
|
||||
self.publish(io, store, now_s, classify(free, self.cfg), free);
|
||||
}
|
||||
|
||||
/// Sample first, then sleep: a process that starts on a full disk must not
|
||||
/// serve a whole interval believing the state is `.ok`. `.boot` so a
|
||||
/// suspended box still sees the interval elapse.
|
||||
pub fn run(self: *Monitor, io: std.Io) std.Io.Cancelable!void {
|
||||
pub fn run(self: *Monitor, io: std.Io, store: ?*events.Store) std.Io.Cancelable!void {
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(sample_interval_s),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
self.sample(io);
|
||||
self.sample(io, store, std.Io.Clock.real.now(io).toSeconds());
|
||||
try interval.sleep(io);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +143,14 @@ pub const Monitor = struct {
|
||||
|
||||
/// Logs on transitions only. A disk that sits at `.warn` for a week
|
||||
/// produces one line, not ten thousand.
|
||||
fn publish(self: *Monitor, next: State, free: u64) void {
|
||||
fn publish(
|
||||
self: *Monitor,
|
||||
io: std.Io,
|
||||
store: ?*events.Store,
|
||||
now_s: i64,
|
||||
next: State,
|
||||
free: u64,
|
||||
) void {
|
||||
const previous: State = @enumFromInt(self.state_raw.swap(@intFromEnum(next), .monotonic));
|
||||
if (previous == next) return;
|
||||
log.warn("disk state {t} -> {t}: {d} bytes free on {s}", .{
|
||||
@@ -145,6 +159,24 @@ pub const Monitor = struct {
|
||||
free,
|
||||
self.data_path,
|
||||
});
|
||||
|
||||
const s = store orelse return;
|
||||
if (next == .ok) {
|
||||
s.resolve(io, now_s, .disk_space, disk_space_key);
|
||||
return;
|
||||
}
|
||||
var buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&buf, "disk state {t} -> {t}: {d} bytes free on {s}", .{
|
||||
previous,
|
||||
next,
|
||||
free,
|
||||
self.data_path,
|
||||
}) catch buf[0..];
|
||||
s.report(io, now_s, .disk_space, disk_space_key, "data directory", switch (next) {
|
||||
.warn => .warning,
|
||||
.critical => .@"error",
|
||||
.ok => unreachable,
|
||||
}, detail);
|
||||
}
|
||||
|
||||
fn sumLogDir(self: *Monitor, io: std.Io, path: [:0]const u8) !u64 {
|
||||
@@ -155,6 +187,26 @@ pub const Monitor = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// The one subject `disk.space` ever has: this box has exactly one data
|
||||
/// directory, and its filesystem is what the thresholds classify.
|
||||
const disk_space_key = "data";
|
||||
|
||||
/// Every probe failure is a warning, not an error: an unreadable filesystem is
|
||||
/// a gap in what the monitor can see, and the state it published last stands.
|
||||
fn probeFailed(
|
||||
store: ?*events.Store,
|
||||
io: std.Io,
|
||||
now_s: i64,
|
||||
operation: []const u8,
|
||||
message: []const u8,
|
||||
err: anyerror,
|
||||
) void {
|
||||
const s = store orelse return;
|
||||
var buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ message, @errorName(err) }) catch buf[0..];
|
||||
s.report(io, now_s, .disk_probe, operation, operation, .warning, detail);
|
||||
}
|
||||
|
||||
fn everyFile(_: []const u8) bool {
|
||||
return true;
|
||||
}
|
||||
@@ -190,6 +242,7 @@ fn sumDir(io: std.Io, dir: std.Io.Dir, accept: *const fn ([]const u8) bool) !u64
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const events_fixture = @import("events_fixture.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
const mb = 1024 * 1024;
|
||||
@@ -280,7 +333,7 @@ test "a sample sizes the databases and ignores every other file" {
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "notes.txt", .data = &[_]u8{'d'} ** 4096 });
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
|
||||
const g = monitor.gauges();
|
||||
try testing.expectEqual(@as(u64, 160), g.db_bytes);
|
||||
@@ -308,7 +361,7 @@ test "a sample sizes every file in the log directory" {
|
||||
const log_path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
|
||||
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", log_path);
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1000), monitor.gauges().log_bytes);
|
||||
try testing.expectEqual(@as(u64, 0), monitor.sample_failures.load(.monotonic));
|
||||
@@ -321,7 +374,7 @@ test "a failed statvfs counts and keeps the previous state" {
|
||||
|
||||
var monitor: Monitor = .init(.{}, std.Io.Dir.cwd(), "./nxdns-no-such-path-7c21", null);
|
||||
monitor.state_raw.store(@intFromEnum(State.warn), .monotonic);
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
|
||||
try testing.expectEqual(State.warn, monitor.state());
|
||||
try testing.expectEqual(@as(u64, 1), monitor.sample_failures.load(.monotonic));
|
||||
@@ -342,7 +395,7 @@ test "an unreadable log directory counts a failure but still publishes a state"
|
||||
".",
|
||||
"./nxdns-no-such-dir-4f8a",
|
||||
);
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), monitor.sample_failures.load(.monotonic));
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
@@ -386,7 +439,7 @@ test "an unreadable data directory fails the scan and keeps the previous gauge"
|
||||
// The handle keeps its read permission from open time, so `iterate` still
|
||||
// lists the file, but path resolution under the directory now fails.
|
||||
try tmp.dir.setPermissions(io, .fromMode(0o600));
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
try tmp.dir.setPermissions(io, .fromMode(0o700));
|
||||
|
||||
try testing.expectEqual(@as(u64, 4096), monitor.gauges().db_bytes);
|
||||
@@ -409,13 +462,115 @@ test "a threshold above the real free space drives the state to critical" {
|
||||
".",
|
||||
null,
|
||||
);
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(State.critical, monitor.state());
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
try testing.expectEqual(@as(u64, 0), monitor.sample_failures.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a disk transition records an episode per severity and closes it on recovery" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
const unreachable_mb = std.math.maxInt(u32);
|
||||
var monitor: Monitor = .init(
|
||||
.{ .min_free_mb = unreachable_mb, .warn_free_mb = unreachable_mb },
|
||||
tmp.dir,
|
||||
".",
|
||||
null,
|
||||
);
|
||||
monitor.sample(io, &fx.store, 1000);
|
||||
try testing.expectEqual(State.critical, monitor.state());
|
||||
try testing.expectEqualStrings("disk.space", try fx.text(
|
||||
"SELECT code FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("error", try fx.text(
|
||||
"SELECT severity FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("data", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
|
||||
// A second critical sample is the same episode, not a second row: `publish`
|
||||
// only reports on a transition.
|
||||
monitor.sample(io, &fx.store, 1060);
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.sample(io, &fx.store, 1120);
|
||||
try testing.expectEqual(State.ok, monitor.state());
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 1120), try fx.count("SELECT resolved_at FROM operational_events"));
|
||||
}
|
||||
|
||||
test "a failed probe opens an episode the next clean pass closes" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var monitor: Monitor = .init(
|
||||
.{ .min_free_mb = 0, .warn_free_mb = 0 },
|
||||
tmp.dir,
|
||||
".",
|
||||
"./nxdns-no-such-dir-4f8a",
|
||||
);
|
||||
monitor.sample(io, &fx.store, 1000);
|
||||
|
||||
try testing.expectEqualStrings("disk.probe", try fx.text(
|
||||
"SELECT code FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("log_dir", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("warning", try fx.text(
|
||||
"SELECT severity FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
|
||||
try tmp.dir.createDirPath(io, "logs");
|
||||
var path_buf: [256]u8 = undefined;
|
||||
monitor.log_dir_path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
|
||||
monitor.sample(io, &fx.store, 1100);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
test "every emit site is inert when the store is absent" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var monitor: Monitor = .init(
|
||||
.{ .min_free_mb = std.math.maxInt(u32), .warn_free_mb = std.math.maxInt(u32) },
|
||||
std.Io.Dir.cwd(),
|
||||
"./nxdns-no-such-path-7c21",
|
||||
"./nxdns-no-such-dir-4f8a",
|
||||
);
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(@as(u64, 1), monitor.sample_failures.load(.monotonic));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
//! Test support: a migrated in-memory `config.db` with an `events.Store` over
|
||||
//! it, for the emitter tests that live beside their own subsystem.
|
||||
//!
|
||||
//! Every emitter takes `?*events.Store` and must work with `null`; the tests
|
||||
//! that prove an emitter *does* record something need a real store, and nine
|
||||
//! subsystems needing the same six lines is what this file removes.
|
||||
//!
|
||||
//! Built in place rather than returned by value: a `Store` holds a `*db.Db`, so
|
||||
//! a fixture that moved after `Store.init` would leave that pointer behind.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const db = @import("db.zig");
|
||||
const events = @import("events.zig");
|
||||
const migrations = @import("migrations.zig");
|
||||
|
||||
pub const Fixture = struct {
|
||||
database: db.Db = undefined,
|
||||
store: events.Store = undefined,
|
||||
text_buf: [1024]u8 = undefined,
|
||||
|
||||
pub fn init(self: *Fixture, io: std.Io, now_s: i64) !void {
|
||||
self.database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer self.database.close();
|
||||
try db.applyPragmas(&self.database, .{});
|
||||
_ = try migrations.migrate(&self.database);
|
||||
self.store = try events.Store.init(io, &self.database, now_s);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Fixture) void {
|
||||
self.database.close();
|
||||
}
|
||||
|
||||
pub fn count(self: *Fixture, sql: []const u8) !i64 {
|
||||
return self.database.queryInt(sql);
|
||||
}
|
||||
|
||||
/// The one column a test names most often, for the newest row of `code`.
|
||||
pub 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];
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@ const std = @import("std");
|
||||
|
||||
const db = @import("db.zig");
|
||||
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");
|
||||
|
||||
@@ -177,6 +178,9 @@ pub const Logger = struct {
|
||||
/// closed and every entry counts as dropped from that point, so a caller
|
||||
/// that sees this must not expect rows.
|
||||
writer_failed: std.atomic.Value(bool),
|
||||
/// Wired by the composition root after `init`, following the
|
||||
/// `gate: ?*disk_monitor.Monitor` idiom. Null in every unit test here.
|
||||
diagnostics: ?*events.Store = null,
|
||||
|
||||
/// `queue_buf.len` is the backpressure cap — the composition root
|
||||
/// (`app.zig:311`) allocates `cfg.logging.query_log_buffer_max` entries,
|
||||
@@ -254,6 +258,9 @@ pub const Logger = struct {
|
||||
// Without a writer there is no consumer, so leaving the queue open
|
||||
// would silently swallow every later entry.
|
||||
self.writer_failed.store(true, .release);
|
||||
// No recovery path claims this episode: the writer is gone for the
|
||||
// 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);
|
||||
return;
|
||||
@@ -400,9 +407,41 @@ 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);
|
||||
self.reportWrite(io, "batch", "a query log batch was dropped", @errorName(err), entries.len);
|
||||
return;
|
||||
};
|
||||
_ = self.rows_written.fetchAdd(entries.len, .monotonic);
|
||||
if (self.diagnostics) |store| {
|
||||
store.resolve(io, std.Io.Clock.real.now(io).toSeconds(), .query_log_write, "batch");
|
||||
}
|
||||
}
|
||||
|
||||
/// An error, not a warning: dropped query rows are gone, and a writer that
|
||||
/// never started means every later row is gone too.
|
||||
fn reportWrite(
|
||||
self: *Logger,
|
||||
io: std.Io,
|
||||
operation: []const u8,
|
||||
message: []const u8,
|
||||
error_name: []const u8,
|
||||
rows: usize,
|
||||
) void {
|
||||
const store = self.diagnostics orelse return;
|
||||
var buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&buf, "{s}: {s} ({d} rows)", .{
|
||||
message,
|
||||
error_name,
|
||||
rows,
|
||||
}) catch buf[0..];
|
||||
store.report(
|
||||
io,
|
||||
std.Io.Clock.real.now(io).toSeconds(),
|
||||
.query_log_write,
|
||||
operation,
|
||||
operation,
|
||||
.@"error",
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
fn countDropped(self: *Logger, n: usize) void {
|
||||
@@ -441,6 +480,7 @@ fn outcomeEntry(outcome: Outcome) ?Entry {
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const events_fixture = @import("events_fixture.zig");
|
||||
const querylog_schema = @import("querylog_schema.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
@@ -881,3 +921,81 @@ test "an empty batch touches neither the database nor the counters" {
|
||||
try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
test "a dropped batch opens an error episode the next good batch closes" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER refuse_boom BEFORE INSERT ON query_log
|
||||
\\WHEN new.client_ip = 'boom'
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
logger.diagnostics = &fx.store;
|
||||
|
||||
var doomed = sampleEntry(10, "poison.example");
|
||||
doomed.setClientIp("boom");
|
||||
const bad = [_]Entry{doomed};
|
||||
try logger.flush(io, &writer, &bad, null);
|
||||
|
||||
try testing.expectEqualStrings("query_log.write", try fx.text("SELECT code FROM operational_events"));
|
||||
try testing.expectEqualStrings("batch", try fx.text("SELECT subject_key FROM operational_events"));
|
||||
try testing.expectEqualStrings("error", try fx.text("SELECT severity FROM operational_events"));
|
||||
|
||||
const good = [_]Entry{sampleEntry(11, "next.example")};
|
||||
try logger.flush(io, &writer, &good, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a writer that cannot prepare leaves an episode no recovery path claims" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// No schema: `BatchWriter.init` cannot prepare against a missing table.
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [8]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
logger.diagnostics = &fx.store;
|
||||
|
||||
try logger.runWriter(io, &database, null);
|
||||
|
||||
try testing.expectEqualStrings("writer", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("error", try fx.text(
|
||||
"SELECT severity FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
|
||||
// The writer returned, so nothing can ever close this. A second run finds
|
||||
// the queue closed and adds no second episode.
|
||||
try logger.runWriter(io, &database, null);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
+113
-4
@@ -55,7 +55,24 @@ fn assertOrdered(list: []const Step) void {
|
||||
/// foreign_keys` is a no-op inside a transaction, so applying it afterwards
|
||||
/// would silently leave referential integrity off.
|
||||
pub fn migrate(database: *db.Db) Error!u32 {
|
||||
return migrateSteps(database, &steps);
|
||||
const version = try migrateSteps(database, &steps);
|
||||
try bridgeOperationalEvents(database);
|
||||
return version;
|
||||
}
|
||||
|
||||
/// **Removable when the v0.1 adoption gate lands.**
|
||||
///
|
||||
/// `operational_events` joined `config_schema.ddl_v1` after databases stamped
|
||||
/// version 1 already existed, and a stamped database never runs step 1 again —
|
||||
/// so on those installs nothing would ever create the table, silently, and the
|
||||
/// diagnostics store would fail to open forever. This runs after the stamp,
|
||||
/// with every statement conditional, and touches no other table.
|
||||
///
|
||||
/// It belongs here and not in `cli.openConfigDb`: that runs *before* migration
|
||||
/// everywhere (`app.zig`, `cli.zig`), so creating the table there would make a
|
||||
/// fresh database's unconditional `CREATE TABLE` in `ddl_v1` fail.
|
||||
fn bridgeOperationalEvents(database: *db.Db) db.Error!void {
|
||||
return database.exec(config_schema.operational_events_bridge);
|
||||
}
|
||||
|
||||
/// Same logic against an injected step list. The seam exists for the rollback
|
||||
@@ -159,7 +176,7 @@ test "migrate on a fresh database creates every table and seeds the default grou
|
||||
const expected = [_][]const u8{
|
||||
"schema_version", "groups", "clients", "client_prefixes",
|
||||
"upstreams", "rules", "local_records", "forward_zones",
|
||||
"blocklist_sources", "group_sources", "settings",
|
||||
"blocklist_sources", "group_sources", "settings", "operational_events",
|
||||
};
|
||||
for (expected) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
@@ -373,9 +390,101 @@ test "delete_order and table_names name exactly the tables the schema creates" {
|
||||
for (config_schema.table_names) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
}
|
||||
// delete_order covers every table except `schema_version`.
|
||||
// delete_order covers every table except two: `schema_version`, which is
|
||||
// the migration's own, and `operational_events`, which is runtime state an
|
||||
// import must never wipe.
|
||||
try testing.expect(!try tableExists(&database, "no_such_table"));
|
||||
try testing.expect(try tableExists(&database, "operational_events"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, config_schema.delete_order.len + 1),
|
||||
@as(i64, config_schema.delete_order.len + 2),
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a version-1 database created without operational_events gains exactly it" {
|
||||
// The silent divergence the bridge exists for: this is what the Pi's
|
||||
// `config.db` looks like — stamped 1, so step 1 never runs again.
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try database.exec(config_schema.ddl_v1);
|
||||
try database.exec("DROP TABLE operational_events;");
|
||||
try database.exec("INSERT INTO schema_version (version) VALUES (1);");
|
||||
try testing.expect(!try tableExists(&database, "operational_events"));
|
||||
|
||||
const tables_before = try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'");
|
||||
const groups_before = try database.queryInt("SELECT count(*) FROM groups");
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), try migrate(&database));
|
||||
|
||||
try testing.expect(try tableExists(&database, "operational_events"));
|
||||
try testing.expectEqual(
|
||||
tables_before + 1,
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
|
||||
);
|
||||
// Both indexes came with it, and no other table moved.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 2),
|
||||
try database.queryInt(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE type='index' AND tbl_name='operational_events'",
|
||||
),
|
||||
);
|
||||
try testing.expectEqual(groups_before, try database.queryInt("SELECT count(*) FROM groups"));
|
||||
}
|
||||
|
||||
test "the bridge does not double-create on a fresh database or on a second run" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
// `ddl_v1` creates the table unconditionally, so a bridge that ran as part
|
||||
// of the step list would fail here rather than be a no-op.
|
||||
try testing.expectEqual(@as(u32, 1), try migrate(&database));
|
||||
const schema_rows = try database.queryInt("SELECT count(*) FROM sqlite_schema");
|
||||
|
||||
try database.exec(
|
||||
\\INSERT INTO operational_events
|
||||
\\ (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences)
|
||||
\\VALUES ('disk.space', 'data', 'data', 'warning', 100, 100, 1);
|
||||
);
|
||||
try testing.expectEqual(@as(u32, 1), try migrate(&database));
|
||||
try testing.expectEqual(schema_rows, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
|
||||
// A `CREATE TABLE IF NOT EXISTS` that had somehow replaced the table would
|
||||
// show up as a lost row, not as a schema difference.
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM operational_events"));
|
||||
}
|
||||
|
||||
test "the partial unique index allows one active row per key and any number of resolved ones" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
_ = try migrate(&database);
|
||||
|
||||
const insert =
|
||||
\\INSERT INTO operational_events
|
||||
\\ (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences, resolved_at)
|
||||
\\VALUES ('blocklist.refresh', 'https://a.example', 'a', 'warning', 100, 100, 1, ?1)
|
||||
;
|
||||
{
|
||||
var stmt = try database.prepare(insert);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindNull(1);
|
||||
try stmt.exec();
|
||||
}
|
||||
{
|
||||
// A second active row for the same (code, subject_key) is what
|
||||
// `report`'s overflow probe exists to avoid, and the index proves it.
|
||||
// Its own statement: `Stmt.reset` re-reports the code of a failed step,
|
||||
// so a reused one would answer `error.Constraint` a second time.
|
||||
var stmt = try database.prepare(insert);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindNull(1);
|
||||
try testing.expectError(error.Constraint, stmt.step());
|
||||
}
|
||||
|
||||
var resolved = try database.prepare(insert);
|
||||
defer resolved.deinit();
|
||||
for ([_]i64{ 200, 300 }) |resolved_at| {
|
||||
try resolved.reset();
|
||||
try resolved.bindInt(1, resolved_at);
|
||||
try resolved.exec();
|
||||
}
|
||||
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM operational_events"));
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
|
||||
try testing.expect(try f.sizeOf("querylog.db-wal") > 0);
|
||||
|
||||
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||
pass.runOnce(io, log_db.database(), null);
|
||||
pass.runOnce(io, log_db.database(), null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned);
|
||||
@@ -396,7 +396,7 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
|
||||
data_path,
|
||||
null,
|
||||
);
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(disk_monitor.State.critical, monitor.state());
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
try testing.expect(monitor.gauges().free_bytes > 0);
|
||||
@@ -418,7 +418,7 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
|
||||
|
||||
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
|
||||
monitor.sample(io);
|
||||
monitor.sample(io, null, 0);
|
||||
try testing.expectEqual(disk_monitor.State.ok, monitor.state());
|
||||
try testing.expect(monitor.writesAllowed());
|
||||
|
||||
|
||||
@@ -88,6 +88,18 @@ pub const OpenResult = struct {
|
||||
database: db.Db,
|
||||
/// Non-null feeds a counter and the `/api/health` rollup.
|
||||
recreated: ?RecreateReason,
|
||||
/// The path the previous file was kept as, by value. It existed only in a
|
||||
/// stack buffer inside `open` before the diagnostics event needed it, and a
|
||||
/// slice of that buffer would dangle the moment `open` returned.
|
||||
///
|
||||
/// Empty when nothing was renamed aside, which `.missing` and a clean open
|
||||
/// both are.
|
||||
aside_buf: [path_buf_len]u8 = undefined,
|
||||
aside_len: u16 = 0,
|
||||
|
||||
pub fn aside(self: *const OpenResult) []const u8 {
|
||||
return self.aside_buf[0..self.aside_len];
|
||||
}
|
||||
};
|
||||
|
||||
pub const Error = db.Error || error{AsideNameCollision} ||
|
||||
@@ -156,7 +168,12 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
|
||||
aside.?,
|
||||
});
|
||||
}
|
||||
return .{ .database = fresh, .recreated = cause };
|
||||
var result: OpenResult = .{ .database = fresh, .recreated = cause };
|
||||
if (aside) |name| {
|
||||
result.aside_len = @intCast(name.len);
|
||||
@memcpy(result.aside_buf[0..name.len], name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The whitelist. `null` means "propagate, do not touch the file".
|
||||
@@ -321,3 +338,53 @@ test "recreatable selects exactly two of db.Error's members" {
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 2), whitelisted);
|
||||
}
|
||||
|
||||
test "a recreate returns the aside name by value and a fresh create returns none" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
|
||||
var path_buf: [path_buf_len]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
|
||||
|
||||
// First open: the file is missing, so nothing is renamed aside. The
|
||||
// one-shot event is deliberately not emitted for this case.
|
||||
var created = try open(io, std.Io.Dir.cwd(), path);
|
||||
created.database.close();
|
||||
try testing.expectEqual(RecreateReason.missing, created.recreated.?);
|
||||
try testing.expectEqualStrings("", created.aside());
|
||||
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
|
||||
|
||||
var recreated = try open(io, std.Io.Dir.cwd(), path);
|
||||
recreated.database.close();
|
||||
try testing.expectEqual(RecreateReason.not_a_database, recreated.recreated.?);
|
||||
try testing.expect(recreated.aside().len != 0);
|
||||
|
||||
// The name is a real file, which is the whole reason it travels out.
|
||||
const kept = std.fs.path.basename(recreated.aside());
|
||||
try tmp.dir.access(io, kept, .{});
|
||||
}
|
||||
|
||||
test "a clean reopen reports no recreate and no aside" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
|
||||
var path_buf: [path_buf_len]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
|
||||
|
||||
var first = try open(io, std.Io.Dir.cwd(), path);
|
||||
first.database.close();
|
||||
|
||||
var second = try open(io, std.Io.Dir.cwd(), path);
|
||||
second.database.close();
|
||||
try testing.expectEqual(@as(?RecreateReason, null), second.recreated);
|
||||
try testing.expectEqualStrings("", second.aside());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+149
-19
@@ -13,6 +13,7 @@ const std = @import("std");
|
||||
|
||||
const db = @import("db.zig");
|
||||
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");
|
||||
@@ -102,15 +103,23 @@ pub const Retention = struct {
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
store: ?*events.Store,
|
||||
) void {
|
||||
add(&self.counters.passes, 1);
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
const cutoff = now - model.retentionSeconds(self.cfg);
|
||||
|
||||
// Diagnostics retention rides this pass rather than a schedule of its
|
||||
// own: one daily housekeeping task, and a box restarted every night
|
||||
// still prunes through `Store.init`.
|
||||
if (store) |s| s.prune(io, now);
|
||||
|
||||
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
|
||||
add(&self.counters.rows_pruned, @intCast(deleted));
|
||||
maintenance(store, io, now, "prune", null);
|
||||
} else |err| {
|
||||
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||
maintenance(store, io, now, "prune", @errorName(err));
|
||||
}
|
||||
|
||||
// Before the vacuum-cadence logic below, which returns early on six
|
||||
@@ -123,14 +132,18 @@ pub const Retention = struct {
|
||||
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);
|
||||
} else |err| {
|
||||
log.warn("retention checkpoint failed: {s}", .{@errorName(err)});
|
||||
maintenance(store, io, now, "checkpoint", @errorName(err));
|
||||
}
|
||||
|
||||
self.passes_since_vacuum += 1;
|
||||
@@ -141,17 +154,37 @@ pub const Retention = struct {
|
||||
if (monitor) |m| if (!m.writesAllowed()) {
|
||||
add(&self.counters.vacuums_gated, 1);
|
||||
log.warn("retention vacuum skipped: the disk monitor refuses writes", .{});
|
||||
maintenance(store, io, now, "vacuum", "the disk monitor refuses writes");
|
||||
return;
|
||||
};
|
||||
|
||||
if (queries_repo.vacuum(database)) {
|
||||
add(&self.counters.vacuums, 1);
|
||||
self.passes_since_vacuum = 0;
|
||||
maintenance(store, io, now, "vacuum", null);
|
||||
} else |err| {
|
||||
log.warn("retention vacuum failed: {s}", .{@errorName(err)});
|
||||
maintenance(store, io, now, "vacuum", @errorName(err));
|
||||
}
|
||||
}
|
||||
|
||||
/// One step's outcome. `reason` null is the success branch of that same
|
||||
/// step in that same pass, which is what closes its episode; a gated vacuum
|
||||
/// is a failure of the step, because the work it owes is still owed.
|
||||
fn maintenance(
|
||||
store: ?*events.Store,
|
||||
io: std.Io,
|
||||
now_s: i64,
|
||||
operation: []const u8,
|
||||
reason: ?[]const u8,
|
||||
) void {
|
||||
const s = store orelse return;
|
||||
const text = reason orelse return s.resolve(io, now_s, .query_log_maintenance, operation);
|
||||
var buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&buf, "retention {s} failed: {s}", .{ operation, text }) catch buf[0..];
|
||||
s.report(io, now_s, .query_log_maintenance, operation, operation, .warning, detail);
|
||||
}
|
||||
|
||||
fn add(counter: *std.atomic.Value(u64), delta: u64) void {
|
||||
_ = counter.fetchAdd(delta, .monotonic);
|
||||
}
|
||||
@@ -182,13 +215,14 @@ pub const Retention = struct {
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
store: ?*events.Store,
|
||||
) std.Io.Cancelable!void {
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(pass_interval_s),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
self.runOnce(io, database, monitor);
|
||||
self.runOnce(io, database, monitor, store);
|
||||
try interval.sleep(io);
|
||||
}
|
||||
}
|
||||
@@ -198,6 +232,7 @@ pub const Retention = struct {
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const events_fixture = @import("events_fixture.zig");
|
||||
const querylog_schema = @import("querylog_schema.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
@@ -243,7 +278,7 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
|
||||
try writeRows(&database, &.{ now - 40 * day, now - 31 * day, now - 29 * day, now - 60 });
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
@@ -267,12 +302,12 @@ test "the cutoff follows retention_days" {
|
||||
try writeRows(&database, &.{now - 3 * day});
|
||||
|
||||
var keeps: Retention = .init(.{ .retention_days = 7 });
|
||||
keeps.runOnce(io, &database, null);
|
||||
keeps.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned);
|
||||
|
||||
var prunes: Retention = .init(.{ .retention_days = 1 });
|
||||
prunes.runOnce(io, &database, null);
|
||||
prunes.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
|
||||
}
|
||||
@@ -287,16 +322,16 @@ test "the seventh pass vacuums and the six before it do not" {
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
for (0..6) |_| {
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||
}
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().checkpoints);
|
||||
|
||||
for (0..7) |_| retention.runOnce(io, &database, null);
|
||||
for (0..7) |_| retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums);
|
||||
}
|
||||
@@ -315,7 +350,7 @@ test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" {
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var gated: Retention = .init(.{});
|
||||
for (0..vacuum_every_passes) |_| gated.runOnce(io, &database, &monitor);
|
||||
for (0..vacuum_every_passes) |_| gated.runOnce(io, &database, &monitor, null);
|
||||
|
||||
// Prune and checkpoint ran on every pass; only the vacuum was refused.
|
||||
try testing.expectEqual(@as(u64, vacuum_every_passes), gated.snapshotStats().passes);
|
||||
@@ -325,12 +360,12 @@ test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" {
|
||||
|
||||
// The vacuum is due again immediately, not seven passes later.
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||
gated.runOnce(io, &database, &monitor);
|
||||
gated.runOnce(io, &database, &monitor, null);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums_gated);
|
||||
|
||||
// And the counter reset, so the next six passes vacuum nothing.
|
||||
for (0..vacuum_every_passes - 1) |_| gated.runOnce(io, &database, &monitor);
|
||||
for (0..vacuum_every_passes - 1) |_| gated.runOnce(io, &database, &monitor, null);
|
||||
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums);
|
||||
}
|
||||
|
||||
@@ -346,7 +381,7 @@ test "a warn state still allows the vacuum" {
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor);
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums_gated);
|
||||
@@ -361,7 +396,7 @@ test "a pass over an empty database still counts" {
|
||||
defer database.close();
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
|
||||
@@ -385,7 +420,7 @@ test "a failing prune counts the pass and leaves the rows alone" {
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||
@@ -412,7 +447,7 @@ test "the upstream-history window is its own, and a one-day query log does not s
|
||||
|
||||
// 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);
|
||||
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
|
||||
@@ -450,7 +485,7 @@ test "the history prune runs on the passes where the vacuum logic returns early"
|
||||
.{ .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);
|
||||
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));
|
||||
|
||||
@@ -460,11 +495,11 @@ test "the history prune runs on the passes where the vacuum logic returns early"
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
@@ -487,13 +522,108 @@ test "the next pass retries what the failed one could not do" {
|
||||
);
|
||||
|
||||
var retention: Retention = .init(.{ .retention_days = 30 });
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
|
||||
|
||||
try database.exec("DROP TRIGGER refuse_delete;");
|
||||
retention.runOnce(io, &database, null);
|
||||
retention.runOnce(io, &database, null, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().passes);
|
||||
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
|
||||
}
|
||||
|
||||
test "a failing prune opens a maintenance episode the next clean pass closes" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try writeRows(&database, &.{now - 40 * 86_400});
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER refuse_delete BEFORE DELETE ON query_log
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
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.
|
||||
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"));
|
||||
try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
|
||||
|
||||
try database.exec("DROP TRIGGER refuse_delete;");
|
||||
retention.runOnce(io, &database, null, &fx.store);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a gated vacuum is a maintenance failure the next ungated pass closes" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, &fx.store);
|
||||
|
||||
try testing.expectEqualStrings("vacuum", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||
retention.runOnce(io, &database, &monitor, &fx.store);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a pass prunes the diagnostics store once" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
// Resolved further back than the retention window, so the pass must drop it.
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
const stale = now - events.Store.resolved_retention_s - 86_400;
|
||||
fx.store.reportResolved(io, stale, .query_log_recreated, "one-shot", "one-shot", .warning, "aside kept");
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
var retention: Retention = .init(.{});
|
||||
retention.runOnce(io, &database, null, &fx.store);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user