milestone 6: dns cache, rate limiting, query logging, disk monitoring and retention

This commit is contained in:
2026-08-01 17:48:12 +02:00
parent 59d94df722
commit 8c50b6617f
13 changed files with 5819 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
//! 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 Phase 7.
//!
//! 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 model = @import("../config/model.zig");
const queries_repo = @import("repositories/queries_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;
pub const Stats = struct {
passes: u64 = 0,
rows_pruned: u64 = 0,
checkpoints: u64 = 0,
vacuums: u64 = 0,
};
pub const Retention = struct {
cfg: model.Logging,
stats: Stats,
pub fn init(cfg: model.Logging) Retention {
return .{ .cfg = cfg, .stats = .{} };
}
/// 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`.
pub fn runOnce(self: *Retention, io: std.Io, database: *db.Db) void {
self.stats.passes += 1;
const cutoff = std.Io.Clock.real.now(io).toSeconds() - model.retentionSeconds(self.cfg);
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
self.stats.rows_pruned += @intCast(deleted);
} else |err| {
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
}
if (queries_repo.checkpointTruncate(database)) {
self.stats.checkpoints += 1;
} else |err| {
log.warn("retention checkpoint failed: {s}", .{@errorName(err)});
}
if (self.stats.passes % vacuum_every_passes != 0) return;
if (queries_repo.vacuum(database)) {
self.stats.vacuums += 1;
} else |err| {
log.warn("retention vacuum failed: {s}", .{@errorName(err)});
}
}
/// Daily loop, first pass immediately. Phase 7 starts it.
///
/// `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; Phase 7
/// opens the second connection. 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) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(pass_interval_s),
.clock = .boot,
};
while (true) {
self.runOnce(io, database);
try interval.sleep(io);
}
}
};
// ---------------------------------------------------------------------------
// 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;
}
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,
.blocked = false,
.block_reason = null,
.response_time_us = null,
.cache_hit = null,
.upstream = 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);
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
try testing.expectEqual(@as(u64, 0), retention.stats.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);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 0), keeps.stats.rows_pruned);
var prunes: Retention = .init(.{ .retention_days = 1 });
prunes.runOnce(io, &database);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), prunes.stats.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);
try testing.expectEqual(@as(u64, 0), retention.stats.vacuums);
}
retention.runOnce(io, &database);
try testing.expectEqual(@as(u64, 7), retention.stats.passes);
try testing.expectEqual(@as(u64, 1), retention.stats.vacuums);
try testing.expectEqual(@as(u64, 7), retention.stats.checkpoints);
for (0..7) |_| retention.runOnce(io, &database);
try testing.expectEqual(@as(u64, 14), retention.stats.passes);
try testing.expectEqual(@as(u64, 2), retention.stats.vacuums);
}
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);
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), retention.stats.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);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
// The checkpoint runs whether or not the prune did.
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
}
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);
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
try database.exec("DROP TRIGGER refuse_delete;");
retention.runOnce(io, &database);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 2), retention.stats.passes);
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
}