136 lines
5.2 KiB
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});
|
|
}
|
|
}
|