From 6bc5b5a209cb46592c6cb9f9d24e1c105bbe80b3 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 4 Aug 2026 16:55:15 -0700 Subject: [PATCH] add .tmp/ to gitignore --- .gitignore | 1 + src/location/GeoLite2.zig | 107 +++++++++++++++++++++++++++++++------- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index c0d38a7..aaf0709 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .zig-cache/ zig-out/ coverage/ +.tmp/ # Zig 0.16 stores fetched packages in-tree rather than the global cache zig-pkg/ diff --git a/src/location/GeoLite2.zig b/src/location/GeoLite2.zig index 79876f1..87d7a49 100644 --- a/src/location/GeoLite2.zig +++ b/src/location/GeoLite2.zig @@ -1,13 +1,62 @@ const std = @import("std"); const log = std.log.scoped(.geolite2); -pub fn ensureDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void { - std.Io.Dir.cwd().access(io, path, .{}) catch { - log.info("GeoLite2 database not found at {s}, will download", .{path}); - try downloadDatabase(allocator, io, path); - log.info("GeoLite2 database downloaded successfully", .{}); +/// Ensures a GeoLite2 database exists at `path`, downloading it when missing +/// and refreshing it once it is older than `max_age_seconds`. +/// +/// Refresh matters: the upstream release is rebuilt continuously and IP +/// allocations move between cities, so a stale database silently resolves +/// clients to the wrong place. Before this, the database was only fetched when +/// absent, so a deployment could -- and did -- run for months on the copy it +/// happened to download first. +/// +/// A missing database is fatal (there is nothing to fall back on). A failed +/// *refresh* is not: an out-of-date database still answers lookups, so a +/// network blip must not stop the service from starting. +pub fn ensureDatabase( + allocator: std.mem.Allocator, + io: std.Io, + path: []const u8, + max_age_seconds: u64, +) !void { + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch |err| switch (err) { + error.FileNotFound => { + log.info("GeoLite2 database not found at {s}, will download", .{path}); + try downloadDatabase(allocator, io, path); + log.info("GeoLite2 database downloaded successfully", .{}); + return; + }, + else => return err, + }; + + const age_seconds = ageInSeconds(io, stat.mtime); + if (age_seconds < max_age_seconds) { + log.info("GeoLite2 database is {d} day(s) old, no refresh needed", .{@divFloor(age_seconds, std.time.s_per_day)}); + return; + } + + log.info( + "GeoLite2 database is {d} day(s) old (limit {d}), refreshing", + .{ @divFloor(age_seconds, std.time.s_per_day), @divFloor(max_age_seconds, std.time.s_per_day) }, + ); + downloadDatabase(allocator, io, path) catch |err| { + // Keep serving from the stale database rather than refusing to start. + log.warn( + "GeoLite2 refresh failed ({t}); continuing with the existing database at {s}", + .{ err, path }, + ); return; }; + log.info("GeoLite2 database refreshed successfully", .{}); +} + +/// Age of `mtime` in seconds, clamped at 0 so a clock skew into the future +/// cannot read as an enormous age and trigger a pointless download. +fn ageInSeconds(io: std.Io, mtime: std.Io.Timestamp) u64 { + const now_ns = std.Io.Timestamp.now(io, .real).nanoseconds; + const age_ns = now_ns - mtime.nanoseconds; + if (age_ns <= 0) return 0; + return @intCast(@divFloor(age_ns, std.time.ns_per_s)); } fn downloadDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void { @@ -18,27 +67,45 @@ fn downloadDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) defer client.deinit(); const uri = try std.Uri.parse(latest_url); - const response_buf = try allocator.alloc(u8, 64 * 1024 * 1024); - defer allocator.free(response_buf); - - var writer = std.Io.Writer.fixed(response_buf); - const result = try client.fetch(.{ - .location = .{ .uri = uri }, - .method = .GET, - .response_writer = &writer, - }); - - if (result.status != .ok) return error.DownloadFailed; // Ensure directory exists if (std.fs.path.dirname(path)) |dir| { try std.Io.Dir.cwd().createDirPath(io, dir); } - try std.Io.Dir.cwd().writeFile(io, .{ - .sub_path = path, - .data = response_buf[0..writer.end], - }); + // Download to a sibling temp file and rename into place. The rename is + // atomic, so a download that dies partway cannot leave a truncated + // database behind, and a database already mmap'd by this process keeps + // pointing at the old inode until it is reopened. + const tmp_path = try std.fmt.allocPrint(allocator, "{s}.download", .{path}); + defer allocator.free(tmp_path); + + { + const file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{}); + // The temp file is only useful if everything below succeeds. + errdefer std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| + log.warn("could not remove partial download {s}: {t}", .{ tmp_path, err }); + defer file.close(io); + + // Stream the response straight to disk. Buffering the whole database in + // memory needed an allocation sized by guesswork, and the guess (64 MiB) + // was already within ~1 MB of the real database size. + var buf: [64 * 1024]u8 = undefined; + var file_writer = file.writer(io, &buf); + const result = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .GET, + .response_writer = &file_writer.interface, + }); + + if (result.status != .ok) { + log.err("GeoLite2 download returned HTTP {d}", .{@intFromEnum(result.status)}); + return error.DownloadFailed; + } + try file_writer.interface.flush(); + } + + try std.Io.Dir.cwd().rename(tmp_path, .cwd(), path, io); } fn getLatestReleaseUrl(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {