//! Query-log retention (PLAN §11.5): a daily pass over `querylog.db` that //! deletes rows older than `logging.retention_days`, truncates the WAL, and //! rewrites the file on every seventh pass. //! //! The pass touches `querylog.db` only. §3.6 walls `config.db` off from //! retention churn, and the `hand_edited=0` client rows of §7.2 are pruned by //! whatever creates them, which is the client tracker (`server/clients.zig`). //! //! Nothing here retries within a pass. A failed step logs at `warn` and the //! next pass, a day later, does the same work again against the same data. 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"); const log = std.log.scoped(.retention); /// A full `VACUUM` rewrites the whole database file. On the SD card of a /// household box that is the most expensive write this program makes, so it /// runs on every seventh pass rather than every night. pub const vacuum_every_passes = 7; /// One day. `retention_days` is the finest granularity the configuration /// expresses, so a finer schedule would prune nothing new. pub const pass_interval_s = 86_400; /// A consistent copy of the counters, for `/metrics` and the health rollup. 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 /// checkpointed, and the vacuum is due again on the next pass. vacuums_gated: u64 = 0, }; /// The live counters. Atomic because the retention task writes them and the web /// task reads them, on different threads, with no lock between the two — the /// same shape the query logger uses for its own counters. 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), }; pub const Retention = struct { cfg: model.Logging, counters: Counters, /// Passes since the last vacuum that succeeded. Plain rather than atomic: /// only the retention task reads or writes it, and no consumer reports it. passes_since_vacuum: u32, pub fn init(cfg: model.Logging) Retention { return .{ .cfg = cfg, .counters = .{}, .passes_since_vacuum = 0 }; } /// The counters, read one at a time. A scrape that lands mid-pass can see a /// pass counted before the rows it pruned are; the alternative is a lock on /// the pass itself, which buys a consistency no consumer needs. pub fn snapshotStats(self: *const Retention) Stats { 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), }; } /// One pass: prune, checkpoint, and on every seventh pass vacuum. /// /// The three steps are independent. A failed prune does not skip the /// checkpoint, because the WAL that the checkpoint truncates was filled by /// the query logger rather than by this pass. /// /// Every failure is a database error, and every database error logs at /// `warn` and leaves the pass counted as done: a pass that returned early /// on the first failure would still be a day away from its retry. /// /// `database` must be a connection no other task uses; see `run`. /// /// `monitor` gates the vacuum only. Prune and checkpoint free space, so a /// full disk is a reason to run them rather than a reason to skip them, /// while a `VACUUM` rewrites the whole file on the very filesystem the /// monitor watches and fails `SQLITE_FULL` there. A null monitor means no /// gate, which is the shape the logger's writer takes. pub fn runOnce( self: *Retention, 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); // One operation, not two: the delete and the coverage watermark it // advances commit together or not at all (`queries_repo`). if (queries_repo.pruneOlderThan(database, cutoff)) |pruned| { add(&self.counters.rows_pruned, @intCast(pruned.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 // 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); } else |err| { log.warn("retention checkpoint failed: {s}", .{@errorName(err)}); maintenance(store, io, now, "checkpoint", @errorName(err)); } self.passes_since_vacuum += 1; if (self.passes_since_vacuum < vacuum_every_passes) return; // The counter is not reset here, so a vacuum the monitor refused is due // again on the very next pass rather than seven passes later. 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); } /// Daily loop, first pass immediately. The composition root starts it as a /// concurrent task (`app.zig`). /// /// `boot` rather than `awake`: a box that suspends overnight must still see /// its day elapse. /// /// `database` must be a connection dedicated to retention: no other task /// may use the same handle while this loop runs. `FULLMUTEX` (`db.zig:218`) /// serializes one SQLite call against another, but a transaction is /// connection state, not call state. On a handle shared with the query /// logger's writer, a prune that lands between that writer's BEGIN and /// COMMIT runs inside the writer's transaction and commits or rolls back /// with the batch, and a checkpoint or a `VACUUM` can land inside a /// transaction that is still open. /// /// Retention takes `database` per call and opens nothing itself; the /// composition root opens the second connection with /// `cli.DataDir.reopenQuerylogDb`. Isolation across the two connections is /// SQLite's own — WAL plus the `busy_timeout` of `db.zig`'s open options — /// so a pass that still loses a race sees `error.Busy` or `error.Locked`, /// logs at `warn`, and repeats the work on the next interval. pub fn run( self: *Retention, 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, store); try interval.sleep(io); } } }; // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const events_fixture = @import("events_fixture.zig"); 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; } fn writeRows(database: *db.Db, timestamps: []const i64) !void { var writer = try queries_repo.BatchWriter.init(database); defer writer.deinit(); var rows: [8]queries_repo.Row = undefined; for (timestamps, rows[0..timestamps.len]) |timestamp, *row| { row.* = .{ .timestamp = timestamp, .domain = "example.com", .client_ip = "192.0.2.10", .qtype = 1, .qclass = 1, .rcode = 0, .blocked = false, .response_time_us = null, .cache_hit = null, .upstream = null, .group_id = 1, .group_name = "default", .policy_action = .allow, .policy_reason = .no_match, .matched = null, .source_id = null, .source_name = null, .cname_target = null, .safe_search_target = null, .route_kind = .upstream, .forward_zone = null, }; } try writer.writeBatch(rows[0..timestamps.len]); } test "a pass prunes the rows past the retention window and keeps the rest" { 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 - 40 * day, now - 31 * day, now - 29 * day, now - 60 }); var retention: Retention = .init(.{ .retention_days = 30 }); 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); try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned); try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints); try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums); } test "the cutoff follows retention_days" { 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; // The same row is inside the window of one configuration and outside the // window of the other. try writeRows(&database, &.{now - 3 * day}); var keeps: Retention = .init(.{ .retention_days = 7 }); 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, null); try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database)); try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned); } test "the seventh pass vacuums and the six before it do not" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openLog(); defer database.close(); var retention: Retention = .init(.{}); for (0..6) |_| { retention.runOnce(io, &database, null, null); try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums); } 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, null); try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes); try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums); } test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openLog(); defer database.close(); // `.critical` is the one state `writesAllowed` refuses on, and it is // published here directly: no real filesystem has to fill up for 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) |_| 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); try testing.expectEqual(@as(u64, vacuum_every_passes), gated.snapshotStats().checkpoints); try testing.expectEqual(@as(u64, 0), gated.snapshotStats().vacuums); try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums_gated); // 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, 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, null); try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums); } test "a warn state still allows the vacuum" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openLog(); defer database.close(); var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null); monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic); var retention: Retention = .init(.{}); 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); } test "a pass over an empty database still counts" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var database = try openLog(); defer database.close(); var retention: Retention = .init(.{}); 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); try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints); try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database)); } test "a failing prune counts the pass and leaves the rows alone" { 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(); 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_days = 30 }); 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); try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned); // The checkpoint runs whether or not the prune did. 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(); const io = threaded.io(); var database = try openLog(); defer database.close(); const now = std.Io.Clock.real.now(io).toSeconds(); try writeRows(&database, &.{ now - 40 * 86_400, now - 39 * 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_days = 30 }); 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, 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")); }