milestone 7: serving pipeline, client tracking, pause and lifecycle
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user