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
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:
@@ -1311,11 +1311,13 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
|
||||
|
||||
// `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
|
||||
// only one the server ever calls. A disabled update stops it after the
|
||||
// startup pass, so the production path runs to completion here with no
|
||||
// interval to wait out.
|
||||
// only one the server ever calls. A disabled update parks it after the
|
||||
// startup pass, and the seam turns that park into the shutdown the task
|
||||
// only otherwise exits on, so the production path runs to completion here
|
||||
// with no interval to wait out.
|
||||
env.mgr.update.enabled = false;
|
||||
try env.mgr.runScheduler(io);
|
||||
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
|
||||
@@ -1555,8 +1557,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
// The restart. The old manager is gone and a new one comes up over the same
|
||||
// directory and the same database with nothing carried across in memory.
|
||||
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
|
||||
// then the startup pass — and a disabled update makes it return rather than
|
||||
// wait out an interval.
|
||||
// then the startup pass — and a disabled update parks it rather than
|
||||
// waiting out an interval; the seam turns that park into shutdown.
|
||||
env.mgr.deinit(io);
|
||||
env.mgr = try manager.Manager.init(
|
||||
gpa,
|
||||
@@ -1566,7 +1568,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
.{ .enabled = false },
|
||||
budget,
|
||||
);
|
||||
try env.mgr.runScheduler(io);
|
||||
env.mgr.schedule_clock = .shutdown_at_first_park;
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
// Nothing was downloaded. The server is still listening, so this is a
|
||||
// decision the pass made rather than a connection it could not have opened.
|
||||
@@ -2456,6 +2459,62 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records the deadline of the first park and then reports the shutdown the
|
||||
/// scheduler loop only otherwise exits on. Its clock never moves, so the
|
||||
/// deadline it captures is exactly `anchor + interval`.
|
||||
const FirstParkRecorder = struct {
|
||||
deadline_s: ?i64 = null,
|
||||
parked: bool = false,
|
||||
|
||||
fn clock(self: *FirstParkRecorder) manager.ScheduleClock {
|
||||
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
|
||||
}
|
||||
|
||||
fn now(_: ?*anyopaque, _: std.Io) i64 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn wait(ctx: ?*anyopaque, _: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
|
||||
const self: *FirstParkRecorder = @ptrCast(@alignCast(ctx.?));
|
||||
self.deadline_s = deadline_s;
|
||||
self.parked = true;
|
||||
return error.Canceled;
|
||||
}
|
||||
};
|
||||
|
||||
test "34: a pass whose refresh failed still advances the schedule anchor" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, http_body);
|
||||
defer fixture.deinit(io);
|
||||
// Every download this pass makes fails.
|
||||
fixture.setRoute(.oversize);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
_ = try seedSource(&env.database, url);
|
||||
|
||||
var recorder: FirstParkRecorder = .{};
|
||||
env.mgr.schedule_clock = recorder.clock();
|
||||
env.mgr.setSchedule(io, true, 2);
|
||||
|
||||
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
|
||||
|
||||
// The startup pass tried the source and failed. The anchor still moved to
|
||||
// that pass's completion, so the next refresh is a full interval away
|
||||
// rather than immediate: a failing source must not become a download loop.
|
||||
try testing.expect(recorder.parked);
|
||||
try testing.expectEqual(@as(?i64, 2 * 3_600), recorder.deadline_s);
|
||||
}
|
||||
|
||||
test "27: one refreshAll pass records one occurrence of a failing source" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
|
||||
+403
-11
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user