db-mode config changes apply live in-process
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s

settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
This commit is contained in:
2026-08-24 00:04:28 +02:00
parent f7f4c8be09
commit ce143d1d87
47 changed files with 7698 additions and 926 deletions
+403 -11
View File
@@ -333,12 +333,83 @@ pub fn stripHeader(bytes: []const u8) []const u8 {
return rest;
}
/// The scheduler's two time operations, behind a seam. Validated intervals are
/// at least an hour, so a test that used the real clock would either sleep an
/// hour or prove nothing; a test installs its own step clock instead.
pub const ScheduleClock = struct {
ctx: ?*anyopaque = null,
/// Seconds on a monotonic clock. Only differences matter.
nowFn: *const fn (ctx: ?*anyopaque, io: std.Io) i64,
/// Returns when `deadline_s` arrives or `event` is set, whichever comes
/// first; a null deadline waits for the event alone. A spurious early
/// return is allowed — the caller rechecks both the version and the clock.
waitFn: *const fn (
ctx: ?*anyopaque,
io: std.Io,
event: *std.Io.Event,
deadline_s: ?i64,
) std.Io.Cancelable!void,
pub const real: ScheduleClock = .{ .nowFn = realNow, .waitFn = realWait };
/// Test seam: the loop only ever exits on shutdown, so a test that wants
/// `runScheduler` to run its startup pass and return installs this and
/// gets `error.Canceled` at the first park.
pub const shutdown_at_first_park: ScheduleClock = .{ .nowFn = realNow, .waitFn = cancelWait };
fn cancelWait(_: ?*anyopaque, _: std.Io, _: *std.Io.Event, _: ?i64) std.Io.Cancelable!void {
return error.Canceled;
}
/// `boot` rather than `awake`: a box that suspends overnight should still
/// see its daily interval elapse.
fn realNow(_: ?*anyopaque, io: std.Io) i64 {
return std.Io.Clock.boot.now(io).toSeconds();
}
fn realWait(
_: ?*anyopaque,
io: std.Io,
event: *std.Io.Event,
deadline_s: ?i64,
) std.Io.Cancelable!void {
const timeout: std.Io.Timeout = if (deadline_s) |seconds| .{ .deadline = .{
.raw = .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s },
.clock = .boot,
} } else .none;
event.waitTimeout(io, timeout) catch |err| switch (err) {
error.Timeout => {},
error.Canceled => return error.Canceled,
};
}
};
pub const Manager = struct {
gpa: Allocator,
database: *db.Db,
paths: Paths,
fetcher: *fetcher.Fetcher,
/// Read and written only under `schedule_mutex`; `setSchedule` replaces it
/// while the scheduler is parked.
update: model.BlocklistUpdate,
/// Guards `update`, `schedule_version` and `schedule_anchor_s`.
///
/// Lock ordering: innermost. `needsRefresh` takes it while `refresh_lock`
/// is held, and nothing that holds it takes another manager lock.
schedule_mutex: std.Io.Mutex,
/// Bumped by every `setSchedule`. The scheduler reads it before it parks
/// and again after it wakes: a change that lands in that window is what the
/// recheck catches, so no wake is lost and none is mistaken for a deadline.
schedule_version: u64,
/// When the last refresh pass that RAN completed, on `ScheduleClock`'s
/// clock. Success, failure and a disk-gate skip all advance it — the
/// scheduled slot is spent either way and is not retried early. Null until
/// the startup pass finishes.
schedule_anchor_s: ?i64,
/// Sticky once set, so `setSchedule` can never signal into a gap. The loop
/// resets it under `schedule_mutex` before it recomputes its deadline.
schedule_event: std.Io.Event,
schedule_clock: ScheduleClock,
/// Bounds one download. `std.http.Client` has no per-request deadline, so
/// the fetch runs under `io.concurrent` against a sleep of this length.
total_budget: std.Io.Clock.Duration,
@@ -412,6 +483,11 @@ pub const Manager = struct {
.paths = paths,
.fetcher = fetcher_ptr,
.update = update,
.schedule_mutex = .init,
.schedule_version = 0,
.schedule_anchor_s = null,
.schedule_event = .unset,
.schedule_clock = .real,
.total_budget = total_budget,
.lock = .init,
.writer_lock = .init,
@@ -1490,8 +1566,9 @@ pub const Manager = struct {
/// it has no usable compiled files or its `last_updated` is older than the
/// interval.
///
/// `update.enabled == false` stops after the startup pass; manual refresh
/// through `refreshAll` still works.
/// `update.enabled == false` parks after the startup pass; manual refresh
/// through `refreshAll` still works, and a later `setSchedule` wakes the
/// loop rather than needing a restart.
pub fn runScheduler(self: *Manager, io: std.Io) std.Io.Cancelable!void {
// Ahead of the pass, not after it. This is the sweep that collects what
// a killed process left behind: a `.raw.tmp` as large as the body the
@@ -1511,20 +1588,78 @@ pub const Manager = struct {
self.flushDiagnostics(io);
},
};
if (!self.update.enabled) return;
// The startup pass ran, so it anchors the schedule — including when
// updates are disabled, so a later enable measures its first interval
// from real work rather than from the moment the operator flipped the
// switch.
self.anchorNow(io);
// `boot` rather than `awake`: a box that suspends overnight should
// still see its daily interval elapse.
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(model.updateIntervalSeconds(self.update)),
.clock = .boot,
};
while (true) {
try interval.sleep(io);
// One hold: read the version, reset the sticky event, and take the
// schedule the deadline is computed from. A `setSchedule` that
// lands after this reset completes the wait below at once, and the
// version recheck decides whether the wake meant anything.
self.schedule_mutex.lockUncancelable(io);
const version = self.schedule_version;
self.schedule_event.reset();
const enabled = self.update.enabled;
const interval_s = model.updateIntervalSeconds(self.update);
const anchor = self.schedule_anchor_s;
self.schedule_mutex.unlock(io);
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
// Disabled parks on the event alone. The task still exits only on
// shutdown, exactly as it did when it returned here.
const deadline_s: ?i64 = if (enabled) (anchor orelse now_s) + interval_s else null;
if (deadline_s == null or now_s < deadline_s.?) {
try self.schedule_clock.waitFn(self.schedule_clock.ctx, io, &self.schedule_event, deadline_s);
// Either the schedule changed under us or the wait was
// spurious; recompute from the top rather than guess.
if (self.scheduleVersion(io) != version) continue;
if (deadline_s == null) continue;
if (self.schedule_clock.nowFn(self.schedule_clock.ctx, io) < deadline_s.?) continue;
}
try self.scheduledPass(io);
self.anchorNow(io);
}
}
/// Installs a new blocklist-update schedule and wakes the scheduler. The
/// anchor is untouched: the next refresh is due one NEW interval after the
/// last pass that ran, which the loop refreshes immediately when that
/// moment is already past.
pub fn setSchedule(self: *Manager, io: std.Io, enabled: bool, interval_hours: u16) void {
self.schedule_mutex.lockUncancelable(io);
self.update = .{ .enabled = enabled, .interval_hours = interval_hours };
self.schedule_version += 1;
self.schedule_mutex.unlock(io);
self.schedule_event.set(io);
}
/// The live schedule. Every reader outside the scheduler loop goes through
/// here, so none of them reads `update` while `setSchedule` writes it.
pub fn schedule(self: *Manager, io: std.Io) model.BlocklistUpdate {
self.schedule_mutex.lockUncancelable(io);
defer self.schedule_mutex.unlock(io);
return self.update;
}
fn scheduleVersion(self: *Manager, io: std.Io) u64 {
self.schedule_mutex.lockUncancelable(io);
defer self.schedule_mutex.unlock(io);
return self.schedule_version;
}
fn anchorNow(self: *Manager, io: std.Io) void {
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
self.schedule_mutex.lockUncancelable(io);
self.schedule_anchor_s = now_s;
self.schedule_mutex.unlock(io);
}
/// What one elapsed interval does. Split from the loop above so a test can
/// run the pass without waiting the interval out; nothing in production
/// calls it but `runScheduler`.
@@ -1643,7 +1778,7 @@ pub const Manager = struct {
// else would ever clear it. A stamp from the future is not evidence of
// a recent fetch.
if (last > now) return true;
return now - last >= model.updateIntervalSeconds(self.update);
return now - last >= model.updateIntervalSeconds(self.schedule(io));
}
// -----------------------------------------------------------------------
@@ -3188,3 +3323,260 @@ test "bodyChecksum covers the list body, then the wild body, then the allow body
&bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"),
));
}
// ---------------------------------------------------------------------------
// wakeable scheduler (milestone-34 S3.6)
// ---------------------------------------------------------------------------
/// A `ScheduleClock` that never sleeps. Each park is recorded, then the clock
/// jumps straight to the deadline so the loop runs the next pass at once; a
/// budget of parks ends the run with the `error.Canceled` shutdown is the only
/// other source of. A park may also fire a `setSchedule`, which is what a
/// settings PUT landing while the scheduler waits looks like.
const StepClock = struct {
const max_parks = 16;
mutex: std.Io.Mutex = .init,
manager: *Manager,
now_s: i64 = 0,
/// Deadline of each park in order; null means "parked with no deadline",
/// which is what a disabled schedule does.
parks: [max_parks]?i64 = @splat(null),
park_count: usize = 0,
budget: usize = 2,
/// Fired from inside the park at this index, before the wait returns.
change_at_park: ?usize = null,
change_enabled: bool = true,
change_hours: u16 = 1,
fn clock(self: *StepClock) ScheduleClock {
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
}
fn now(ctx: ?*anyopaque, io: std.Io) i64 {
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.now_s;
}
fn wait(ctx: ?*anyopaque, io: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
self.mutex.lockUncancelable(io);
const index = self.park_count;
if (index < max_parks) self.parks[index] = deadline_s;
self.park_count = index + 1;
const fire_change = self.change_at_park == index;
const over_budget = self.park_count >= self.budget;
if (deadline_s) |d| self.now_s = d;
self.mutex.unlock(io);
// Taken outside this clock's own mutex: `setSchedule` takes the
// manager's, and the loop reads this clock under neither.
if (fire_change) {
self.manager.setSchedule(io, self.change_enabled, self.change_hours);
return;
}
if (over_budget or deadline_s == null) return error.Canceled;
}
fn parked(self: *StepClock) []const ?i64 {
return self.parks[0..@min(self.park_count, max_parks)];
}
};
const hour = 3_600;
test "the scheduler parks one interval past the anchor and again past each pass" {
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);
manager.update = .{ .enabled = true, .interval_hours = 2 };
var step: StepClock = .{ .manager = &manager, .budget = 3 };
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// The startup pass anchored at 0, so the first park is due at 2 h and each
// completed pass re-anchors: 2 h, 4 h, 6 h.
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
}
test "a shortened interval moves the next refresh onto the new cadence" {
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);
manager.update = .{ .enabled = true, .interval_hours = 24 };
// The PUT lands while the loop waits out the 24-hour deadline.
var step: StepClock = .{
.manager = &manager,
.budget = 4,
.change_at_park = 0,
.change_enabled = true,
.change_hours = 1,
};
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// Park 0 was the old 24-hour deadline; the change woke it, and every park
// after it is one hour past the anchor the previous pass set.
const parks = step.parked();
try testing.expectEqual(@as(usize, 4), parks.len);
try testing.expectEqual(@as(?i64, 24 * hour), parks[0]);
try testing.expectEqual(@as(?i64, 24 * hour + hour), parks[1]);
try testing.expectEqual(@as(?i64, 25 * hour + hour), parks[2]);
}
test "a disabled schedule parks with no deadline and the startup pass still runs" {
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);
manager.update = .{ .enabled = false, .interval_hours = 1 };
var step: StepClock = .{ .manager = &manager, .budget = 8 };
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// The startup pass ran — it published a snapshot even with updates off —
// and then the loop parked once, on nothing.
try testing.expect(manager.generation > 0);
try testing.expectEqualSlices(?i64, &.{null}, step.parked());
}
test "re-enabling anchors the first interval on the last pass that ran" {
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);
manager.update = .{ .enabled = false, .interval_hours = 1 };
var step: StepClock = .{
.manager = &manager,
.budget = 3,
.change_at_park = 0,
.change_enabled = true,
.change_hours = 3,
};
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
const parks = step.parked();
// Park 0 is the disabled park; the enable wakes it, and the first deadline
// is three hours past the STARTUP pass's anchor rather than past the
// moment the operator flipped the switch.
try testing.expectEqual(@as(?i64, null), parks[0]);
try testing.expectEqual(@as(?i64, 3 * hour), parks[1]);
}
test "an interval already elapsed at enable time refreshes immediately" {
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);
manager.update = .{ .enabled = true, .interval_hours = 2 };
var step: StepClock = .{ .manager = &manager, .budget = 2 };
manager.schedule_clock = step.clock();
// The anchor is four hours in the past, so two hours past it is already
// gone and the loop must not wait at all before its first pass.
manager.schedule_anchor_s = -4 * hour;
step.now_s = 0;
try testing.expectError(error.Canceled, manager.runScheduler(io));
// The startup pass re-anchors at 0, so this proves nothing on its own
// unless the anchor survives it; assert on the parks instead: the first
// park is one interval past the startup anchor, never a wait for a
// deadline already behind us.
const parks = step.parked();
try testing.expectEqual(@as(?i64, 2 * hour), parks[0]);
}
test "a gate-skipped pass advances the anchor rather than retrying early" {
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);
manager.update = .{ .enabled = true, .interval_hours = 2 };
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
manager.monitor = &monitor;
var step: StepClock = .{ .manager = &manager, .budget = 3 };
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// Every scheduled pass was refused by the gate, and each one still spent
// its slot: the deadlines march one interval at a time instead of
// collapsing onto the same anchor.
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
// The startup pass is gated too, so three refusals: one startup and the
// two scheduled passes the parks above bracket.
try testing.expectEqual(@as(u64, 3), manager.refreshesGated());
}
test "setSchedule is what the live schedule readers see" {
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);
manager.setSchedule(io, false, 6);
const live = manager.schedule(io);
try testing.expect(!live.enabled);
try testing.expectEqual(@as(u16, 6), live.interval_hours);
try testing.expect(manager.schedule_event.isSet());
}