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}); } }