milestone 6: dns cache, rate limiting, query logging, disk monitoring and retention
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user