milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+77
View File
@@ -44,6 +44,7 @@ const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const compiler = @import("compiler.zig");
const fetcher = @import("fetcher.zig");
const matcher = @import("matcher.zig");
@@ -221,6 +222,15 @@ pub const Manager = struct {
/// Owns the `statuses` table. The entries themselves borrow nothing.
status_arena: std.heap.ArenaAllocator,
/// The §11.6 disk gate (ruling 17). Set by the composition root after
/// `init` and before `runScheduler` starts; null disables gating, which is
/// what every test and `nxdns check` want. Only the scheduler consults it —
/// see `refreshGated`.
monitor: ?*disk_monitor.Monitor = null,
/// Scheduled refresh passes skipped by the disk gate. Phase 8's health
/// rollup reads it through `refreshesGated`.
refreshes_gated: std.atomic.Value(u64) = .init(0),
pub const Error = error{
OutOfMemory,
Canceled,
@@ -958,6 +968,7 @@ pub const Manager = struct {
};
while (true) {
try interval.sleep(io);
if (self.refreshGated()) continue;
self.refreshAll(io) catch |err| switch (err) {
error.Canceled => return error.Canceled,
else => log.warn("blocklist refresh pass failed: {s}", .{@errorName(err)}),
@@ -965,12 +976,42 @@ pub const Manager = struct {
}
}
/// The §11.6 gate, consulted by scheduled passes only (ruling 17). A
/// download writes tens of megabytes into the blocklist directory and the
/// compile writes as much again, which is exactly the "non-essential write"
/// a critically full disk must not take.
///
/// `reload` and `refreshAll` are deliberately not gated: both are operator
/// actions (the composition root's startup load, Phase 8's manual refresh),
/// and an operator who asks for a refresh on a full disk has asked for it.
///
/// Counting happens here, so a caller cannot skip a pass without recording
/// it. One `warn` line per skipped pass — at a 24-hour interval that is one
/// line a day, and the disk monitor already logs the state change itself.
fn refreshGated(self: *Manager) bool {
const monitor = self.monitor orelse return false;
if (monitor.writesAllowed()) return false;
_ = self.refreshes_gated.fetchAdd(1, .monotonic);
log.warn("free space is critical; skipping the scheduled blocklist refresh", .{});
return true;
}
/// Scheduled refresh passes the disk gate has skipped.
pub fn refreshesGated(self: *const Manager) u64 {
return self.refreshes_gated.load(.monotonic);
}
fn startupPass(self: *Manager, io: std.Io) Error!void {
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
// Ahead of the gate on purpose: loading the compiled files that already
// exist is a read. A full disk must not cost the household its
// filtering as well as its downloads.
try self.reloadLocked(io);
if (self.refreshGated()) return;
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
defer rows.deinit(self.gpa);
defer sources_repo.freeSourceRows(self.gpa, rows.items);
@@ -1439,6 +1480,42 @@ test "acquire before any reload returns null and holds no lock" {
manager.lock.unlock(io);
}
test "the disk gate skips a scheduled refresh only while writes are critical" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var f: fetcher.Fetcher = undefined;
var manager = try testManager(&database, &f);
defer manager.deinit(io);
// No monitor: every pass runs, which is what the tests and `check` rely on.
try testing.expect(!manager.refreshGated());
try testing.expectEqual(@as(u64, 0), manager.refreshesGated());
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
manager.monitor = &monitor;
// `.ok` and `.warn` both allow writes: only `critical` stops them.
try testing.expect(!manager.refreshGated());
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
try testing.expect(!manager.refreshGated());
try testing.expectEqual(@as(u64, 0), manager.refreshesGated());
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
try testing.expect(manager.refreshGated());
try testing.expect(manager.refreshGated());
try testing.expectEqual(@as(u64, 2), manager.refreshesGated());
// Free space recovers and the schedule resumes; the counter keeps its total.
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
try testing.expect(!manager.refreshGated());
try testing.expectEqual(@as(u64, 2), manager.refreshesGated());
}
test "statusSnapshot on an empty manager copies nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();