wttr/src/location/Refresher.zig

136 lines
5.2 KiB
Zig

const std = @import("std");
const GeoLite2 = @import("GeoLite2.zig");
const GeoIp = @import("GeoIp.zig");
const Refresher = @This();
const log = std.log.scoped(.geolite2_refresh);
allocator: std.mem.Allocator,
io: std.Io,
geoip: *GeoIp,
db_path: []const u8,
max_age_seconds: u64,
check_interval_seconds: u64,
/// Background refresh of the GeoLite2 database.
///
/// Runs off-request on its own unit of concurrency: the download is tens of
/// megabytes, so doing it inside a request handler would stall that request and
/// occupy a worker thread. Doing it at startup would either delay boot or, worse,
/// make a transient network failure fatal.
///
/// Replacing the file is not enough on its own. The database is mapped with
/// `MMDB_MODE_MMAP`, so this process keeps reading the old inode until it
/// reopens; `GeoIp.reload` performs that reopen under its lock.
pub fn run(self: Refresher) void {
// Check once up front so a database that is already stale at boot is
// replaced promptly rather than after a full interval.
while (true) {
self.checkOnce();
std.Io.sleep(
self.io,
.fromNanoseconds(@intCast(self.check_interval_seconds * std.time.ns_per_s)),
.real,
) catch {
// Cancelled, which is how shutdown stops this loop.
log.debug("refresher stopping", .{});
return;
};
}
}
fn checkOnce(self: Refresher) void {
const age = GeoLite2.ageInSeconds(self.io, self.db_path) orelse return;
if (age < self.max_age_seconds) {
log.debug(
"GeoLite2 database is {d} day(s) old, under the {d} day limit",
.{ @divFloor(age, std.time.s_per_day), @divFloor(self.max_age_seconds, std.time.s_per_day) },
);
return;
}
log.info(
"GeoLite2 database is {d} day(s) old (limit {d} day(s)), refreshing",
.{ @divFloor(age, std.time.s_per_day), @divFloor(self.max_age_seconds, std.time.s_per_day) },
);
GeoLite2.download(self.allocator, self.io, self.db_path) catch |err| {
// A stale database still answers lookups. Keep serving from it.
log.warn(
"GeoLite2 download failed ({t}); continuing with the existing database",
.{err},
);
return;
};
self.geoip.reload() catch |err| {
// The new file is on disk but this process could not map it. The old
// handle is still installed and usable, so keep going and retry on the
// next interval.
log.err(
"GeoLite2 downloaded but reload failed ({t}); still serving the previous database",
.{err},
);
return;
};
log.info("GeoLite2 database refreshed", .{});
}
test "ageOf clamps a future mtime to zero" {
const now: std.Io.Timestamp = .fromNanoseconds(1_000 * std.time.ns_per_s);
const future: std.Io.Timestamp = .fromNanoseconds(2_000 * std.time.ns_per_s);
try std.testing.expectEqual(@as(u64, 0), GeoLite2.ageOf(now, future));
}
test "ageOf reports whole seconds elapsed" {
const mtime: std.Io.Timestamp = .fromNanoseconds(1_000 * std.time.ns_per_s);
const now: std.Io.Timestamp = .fromNanoseconds(1_090 * std.time.ns_per_s);
try std.testing.expectEqual(@as(u64, 90), GeoLite2.ageOf(now, mtime));
}
test "ageOf truncates sub-second remainders rather than rounding up" {
const mtime: std.Io.Timestamp = .fromNanoseconds(0);
const now: std.Io.Timestamp = .fromNanoseconds(std.time.ns_per_s - 1);
try std.testing.expectEqual(@as(u64, 0), GeoLite2.ageOf(now, mtime));
}
test "ageOf spans days for the staleness comparison" {
const mtime: std.Io.Timestamp = .fromNanoseconds(0);
const now: std.Io.Timestamp = .fromNanoseconds(31 * std.time.s_per_day * std.time.ns_per_s);
const age = GeoLite2.ageOf(now, mtime);
try std.testing.expectEqual(@as(u64, 31), @divFloor(age, std.time.s_per_day));
try std.testing.expect(age >= 30 * std.time.s_per_day);
}
/// Watches for operator-requested reloads (SIGHUP) and applies them.
///
/// Separate from `run` because the two have unrelated cadences: staleness is
/// checked on the order of a day, while a reload request should be picked up
/// promptly. Folding them together would mean either polling the signal flag
/// once a day or waking the staleness check every second.
pub fn watchReloads(self: Refresher) void {
const Signals = @import("../Signals.zig");
while (true) {
std.Io.sleep(self.io, .fromMilliseconds(Signals.poll_interval_ms), .real) catch {
log.debug("reload watcher stopping", .{});
return;
};
if (!Signals.takeReloadRequest()) continue;
log.info("SIGHUP received, reloading on-disk state", .{});
// Pins are the common case (an operator just ran `wttr pin`), and a
// failure here leaves the previous overrides installed.
self.geoip.reloadPins() catch |err|
log.err("could not reload pins ({t}); keeping the previous overrides", .{err});
// Also reopen the database, so replacing the file by hand does not
// require waiting for the staleness check or restarting the server.
self.geoip.reload() catch |err|
log.err("could not reload the GeoLite2 database ({t}); keeping the previous one", .{err});
}
}