Compare commits

..

No commits in common. "c26533fe84c52854da8a3d2ba625702eea966c09" and "157cf448717fe64ad6b97cced65405e515aa0610" have entirely different histories.

11 changed files with 39 additions and 711 deletions

171
src/cache/Cache.zig vendored
View file

@ -12,28 +12,10 @@ io: std.Io,
lru: Lru, lru: Lru,
/// Cache directory for L2 persistent cache /// Cache directory for L2 persistent cache
cache_dir: ?[]const u8, cache_dir: ?[]const u8,
/// Request handlers share one cache across httpz's thread pool. Exclusive
/// rather than read/write because even reads mutate: `Lru` bumps access counts
/// and drops expired entries, and an L2 hit promotes into L1. Held across L2
/// file access too, so a reader never sees a file another thread is rewriting.
mutex: std.Io.Mutex = .init,
pub const Config = struct { pub const Config = struct {
max_entries: usize = 1_000, max_entries: usize = 1_000,
cache_dir: ?[]const u8, cache_dir: ?[]const u8,
/// How long entries survive past their TTL, in memory and on disk, so
/// `getStale` can serve them while the upstream is unavailable. `get` is
/// unaffected: it only ever returns fresh entries.
stale_retention_s: u64 = 0,
};
/// An entry returned by `getStale`. `expires` lets the caller say how old the
/// data it is serving is.
pub const Entry = struct {
/// Owned by the caller of `getStale`.
value: []const u8,
/// Expiry time, in milliseconds since the epoch. In the past for stale data.
expires: i64,
}; };
pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !*Cache { pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !*Cache {
@ -52,7 +34,6 @@ pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !*Cache {
.cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null, .cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null,
}; };
cache.lru.retention_ms = @intCast(config.stale_retention_s * std.time.ms_per_s);
cache.lru.setEvictionCallback(cache, evictCallback); cache.lru.setEvictionCallback(cache, evictCallback);
// Clean up expired files and populate L1 cache from L2 // Clean up expired files and populate L1 cache from L2
@ -70,36 +51,9 @@ fn evictCallback(ctx: *anyopaque, key: []const u8) void {
self.deleteFile(key); self.deleteFile(key);
} }
/// Returns a copy of the value, owned by the caller, only if it has not pub fn get(self: *Cache, key: []const u8) ?[]const u8 {
/// expired. A copy because the cached one can be replaced or evicted by // Check L1 (memory) first
/// another thread the moment the lock is released. if (self.lru.get(key)) |value| return value;
pub fn get(self: *Cache, allocator: std.mem.Allocator, key: []const u8) !?[]const u8 {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const entry = self.lookup(key, now) orelse return null;
if (now > entry.expires) return null;
return try allocator.dupe(u8, entry.value);
}
/// Returns the value whether fresh or expired-but-retained, copied as in
/// `get`. Meant as a fallback when refreshing an expired entry has failed.
pub fn getStale(self: *Cache, allocator: std.mem.Allocator, key: []const u8) !?Entry {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const entry = self.lookup(key, now) orelse return null;
return .{ .value = try allocator.dupe(u8, entry.value), .expires = entry.expires };
}
/// Caller must hold `mutex`. The returned value is borrowed from L1.
fn lookup(self: *Cache, key: []const u8, now_ms: i64) ?Entry {
// Check L1 (memory) first. A retained-but-expired L1 entry is the newest
// copy there is, so it also short-circuits the disk read.
if (self.lru.lookup(key, now_ms)) |item|
return .{ .value = item.value, .expires = item.expires };
// Check L2 (disk) // Check L2 (disk)
const cached = self.loadFromFile(key) catch return null; const cached = self.loadFromFile(key) catch return null;
@ -108,14 +62,10 @@ fn lookup(self: *Cache, key: []const u8, now_ms: i64) ?Entry {
// L2 exists - promote to L1 // L2 exists - promote to L1
self.lru.put(key, cached.value, cached.expires) catch return null; self.lru.put(key, cached.value, cached.expires) catch return null;
const item = self.lru.lookup(key, now_ms) orelse return null; return self.lru.get(key);
return .{ .value = item.value, .expires = item.expires };
} }
pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void { pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); const expires = now + @as(i64, @intCast(ttl_seconds * 1000));
@ -157,8 +107,8 @@ const CacheEntry = struct {
}; };
/// Loads cached value from file (path calculated by the key). An error will be thrown /// Loads cached value from file (path calculated by the key). An error will be thrown
/// if the file access fails OR if the data is past its retention window, in /// if the file access fails OR if the data has expired.
/// which case the file is deleted. Expired-but-retained entries are returned. /// If the data has expired, the file will be deleted
fn loadFromFile(self: *Cache, key: []const u8) !CacheEntry { fn loadFromFile(self: *Cache, key: []const u8) !CacheEntry {
if (self.cache_dir == null) return error.NoCacheDir; if (self.cache_dir == null) return error.NoCacheDir;
@ -172,8 +122,8 @@ fn loadFromFile(self: *Cache, key: []const u8) !CacheEntry {
} }
/// Loads cached value from file (using a path). An error will be thrown /// Loads cached value from file (using a path). An error will be thrown
/// if the file access fails OR if the data is past its retention window, in /// if the file access fails OR if the data has expired.
/// which case the file is deleted. Expired-but-retained entries are returned. /// If the data has expired, the file will be deleted
fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry { fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
const file = try std.Io.Dir.cwd().openFile(self.io, file_path, .{}); const file = try std.Io.Dir.cwd().openFile(self.io, file_path, .{});
defer file.close(self.io); defer file.close(self.io);
@ -185,10 +135,10 @@ fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
const cached = try deserialize(self.allocator, reader); const cached = try deserialize(self.allocator, reader);
errdefer cached.deinit(self.allocator); errdefer cached.deinit(self.allocator);
// Check if past retention. Merely expired entries are kept: they are the // Check if expired
// fallback `getStale` serves while the upstream is down.
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
if (cached.expires + self.lru.retention_ms <= now) { if (cached.expires <= now) {
// We're expired, delete expired file
self.deleteFile(cached.key); self.deleteFile(cached.key);
return error.Expired; return error.Expired;
} }
@ -401,16 +351,12 @@ test "L1/L2 cache flow" {
const cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = cache_dir }); const cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = cache_dir });
defer cache.deinit(); defer cache.deinit();
// Values from get/getStale are copies the caller owns
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const copies = arena.allocator();
// Put item in cache // Put item in cache
try cache.put("key1", "value1", 900); try cache.put("key1", "value1", 900);
// Should be in L1 // Should be in L1
try std.testing.expectEqualStrings("value1", (try cache.get(copies, "key1")).?); try std.testing.expectEqualStrings("value1", cache.get("key1").?);
// Manually remove from L1 to simulate eviction // Manually remove from L1 to simulate eviction
if (cache.lru.map.fetchRemove("key1")) |kv| { if (cache.lru.map.fetchRemove("key1")) |kv| {
@ -422,99 +368,8 @@ test "L1/L2 cache flow" {
try std.testing.expect(cache.lru.get("key1") == null); try std.testing.expect(cache.lru.get("key1") == null);
// Get should promote from L2 to L1 // Get should promote from L2 to L1
try std.testing.expectEqualStrings("value1", (try cache.get(copies, "key1")).?); try std.testing.expectEqualStrings("value1", cache.get("key1").?);
// Now it should be in L1 // Now it should be in L1
try std.testing.expectEqualStrings("value1", cache.lru.get("key1").?); try std.testing.expectEqualStrings("value1", cache.lru.get("key1").?);
} }
test "stale entries are retained for getStale but never returned by get" {
const allocator = std.testing.allocator;
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const cache_dir_len = try tmp_dir.dir.realPath(std.testing.io, &path_buf);
const cache_dir = path_buf[0..cache_dir_len];
const cache = try Cache.init(allocator, std.testing.io, .{
.max_entries = 10,
.cache_dir = cache_dir,
.stale_retention_s = 3600,
});
defer cache.deinit();
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const copies = arena.allocator();
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
// Expired a minute ago, still inside the hour of retention. Written only to
// L2 so the disk path is what gets exercised, as it is after a restart.
try cache.saveToFile("recent", "old-but-usable", now - 60_000);
try std.testing.expect(try cache.get(copies, "recent") == null);
const stale = (try cache.getStale(copies, "recent")).?;
try std.testing.expectEqualStrings("old-but-usable", stale.value);
try std.testing.expectEqual(now - 60_000, stale.expires);
// Now promoted to L1, and still not fresh from there either
try std.testing.expect(try cache.get(copies, "recent") == null);
try std.testing.expectEqualStrings("old-but-usable", (try cache.getStale(copies, "recent")).?.value);
// Expired two hours ago: past retention, unusable even as stale, and the
// file is cleaned up.
try cache.saveToFile("ancient", "too-old", now - 2 * 3600 * 1000);
try std.testing.expect(try cache.getStale(copies, "ancient") == null);
try std.testing.expectError(error.FileNotFound, cache.loadFromFile("ancient"));
// Fresh entries come back from both
try cache.put("fresh", "new", 900);
try std.testing.expectEqualStrings("new", (try cache.get(copies, "fresh")).?);
try std.testing.expectEqualStrings("new", (try cache.getStale(copies, "fresh")).?.value);
}
test "returned values survive the entry being replaced" {
const allocator = std.testing.allocator;
const cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = null });
defer cache.deinit();
try cache.put("k", "first", 900);
const value = (try cache.get(allocator, "k")).?;
defer allocator.free(value);
// Before values were copied, this freed the memory `value` pointed at
try cache.put("k", "second", 900);
try std.testing.expectEqualStrings("first", value);
}
test "concurrent puts and gets on shared keys" {
// Many threads hammering a handful of keys through a cache small enough to
// keep evicting. Without the lock this corrupts the map or frees values out
// from under readers; under the debug allocator that crashes.
const allocator = std.testing.allocator;
const cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 4, .cache_dir = null });
defer cache.deinit();
const Worker = struct {
fn run(c: *Cache, id: usize) void {
var key_buf: [16]u8 = undefined;
var value_buf: [32]u8 = undefined;
for (0..2_000) |i| {
const key = std.fmt.bufPrint(&key_buf, "k{d}", .{i % 8}) catch unreachable;
const value = std.fmt.bufPrint(&value_buf, "t{d}-i{d}", .{ id, i }) catch unreachable;
c.put(key, value, 900) catch @panic("put failed");
if (c.get(std.testing.allocator, key) catch @panic("get failed")) |got| {
// Whatever thread wrote it, it must be an intact value
std.debug.assert(got[0] == 't');
std.testing.allocator.free(got);
}
}
}
};
var threads: [8]std.Thread = undefined;
for (&threads, 0..) |*t, id| t.* = try std.Thread.spawn(.{}, Worker.run, .{ cache, id });
for (threads) |t| t.join();
try std.testing.expect(cache.lru.map.count() <= 4);
}

71
src/cache/Lru.zig vendored
View file

@ -1,11 +1,3 @@
//! Size-capped map with per-entry expiry.
//!
//! Not thread-safe, and every operation mutates (even `get` bumps an access
//! count and may drop an expired entry), so owners shared between threads must
//! serialize all access with an exclusive lock. Returned slices point into
//! entries the next `put`, eviction, or expiry frees, so copy them before
//! releasing that lock.
const std = @import("std"); const std = @import("std");
const Lru = @This(); const Lru = @This();
@ -15,10 +7,6 @@ allocator: std.mem.Allocator,
io: std.Io, io: std.Io,
map: std.StringHashMap(Entry), map: std.StringHashMap(Entry),
max_entries: usize, max_entries: usize,
/// How long an entry is kept after it expires, so it can still be served as a
/// fallback when the upstream is down. `get` never returns such an entry; only
/// `lookup` does. Zero drops entries as soon as they expire.
retention_ms: i64 = 0,
evict_fn: ?*const fn (ctx: *anyopaque, key: []const u8) void = null, evict_fn: ?*const fn (ctx: *anyopaque, key: []const u8) void = null,
evict_ctx: ?*anyopaque = null, evict_ctx: ?*anyopaque = null,
@ -43,33 +31,22 @@ pub fn setEvictionCallback(self: *Lru, ctx: *anyopaque, callback: *const fn (ctx
} }
pub fn get(self: *Lru, key: []const u8) ?[]const u8 { pub fn get(self: *Lru, key: []const u8) ?[]const u8 {
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const item = self.lookup(key, now) orelse return null;
if (now > item.expires) return null;
return item.value;
}
/// Returns the entry whether fresh or expired-but-retained, leaving the
/// freshness decision to the caller. Entries past the retention window are
/// dropped.
pub fn lookup(self: *Lru, key: []const u8, now_ms: i64) ?Iterator.Item {
var entry = self.map.getPtr(key) orelse return null; var entry = self.map.getPtr(key) orelse return null;
if (now_ms > entry.expires + self.retention_ms) { const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
if (now > entry.expires) {
self.remove(key); self.remove(key);
return null; return null;
} }
entry.access_count += 1; entry.access_count += 1;
return .{ .key = key, .value = entry.value, .expires = entry.expires }; return entry.value;
} }
pub fn put(self: *Lru, key: []const u8, value: []const u8, expires: i64) !void { pub fn put(self: *Lru, key: []const u8, value: []const u8, expires: i64) !void {
// Replacing, not evicting: the callback is skipped, since the owner may if (self.map.get(key)) |old_entry| {
// already have persisted the new value under the same key. self.allocator.free(old_entry.value);
if (self.map.fetchRemove(key)) |old| { _ = self.map.remove(key);
self.allocator.free(old.value.value);
self.allocator.free(old.key);
} }
if (self.map.count() >= self.max_entries) { if (self.map.count() >= self.max_entries) {
@ -77,9 +54,7 @@ pub fn put(self: *Lru, key: []const u8, value: []const u8, expires: i64) !void {
} }
const key_copy = try self.allocator.dupe(u8, key); const key_copy = try self.allocator.dupe(u8, key);
errdefer self.allocator.free(key_copy);
const value_copy = try self.allocator.dupe(u8, value); const value_copy = try self.allocator.dupe(u8, value);
errdefer self.allocator.free(value_copy);
try self.map.put(key_copy, .{ try self.map.put(key_copy, .{
.value = value_copy, .value = value_copy,
@ -179,38 +154,4 @@ test "LRU expired entry returns null" {
// Get should return null for expired entry // Get should return null for expired entry
const result = lru.get("key1"); const result = lru.get("key1");
try std.testing.expect(result == null); try std.testing.expect(result == null);
// And with no retention it is gone entirely
try std.testing.expect(lru.lookup("key1", now) == null);
}
test "LRU retains expired entries for lookup within the retention window" {
var lru = try Lru.init(std.testing.allocator, std.testing.io, 10);
defer lru.deinit();
lru.retention_ms = 60_000;
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
try lru.put("recent", "v1", now - 1000);
try lru.put("ancient", "v2", now - 120_000);
// Expired, so never served as fresh...
try std.testing.expect(lru.get("recent") == null);
// ...but still available to a caller willing to take stale data
const item = lru.lookup("recent", now).?;
try std.testing.expectEqualStrings("v1", item.value);
try std.testing.expectEqual(now - 1000, item.expires);
// Past the window it is dropped
try std.testing.expect(lru.lookup("ancient", now) == null);
try std.testing.expect(!lru.map.contains("ancient"));
}
test "LRU replacing a key frees the old key and value" {
// std.testing.allocator fails the test on a leak, which is the assertion
var lru = try Lru.init(std.testing.allocator, std.testing.io, 10);
defer lru.deinit();
try lru.put("key1", "value1", 9999999999999);
try lru.put("key1", "value2", 9999999999999);
try std.testing.expectEqualStrings("value2", lru.get("key1").?);
try std.testing.expectEqual(@as(u32, 1), lru.map.count());
} }

View file

@ -83,18 +83,6 @@ pub fn handleWeather(
res.body = help.translation_page; res.body = help.translation_page;
return; return;
} }
// Any other `:` name is an endpoint we do not have, not a place
res.status = 404;
res.content_type = .TEXT;
res.body = "Unknown endpoint\n";
return;
}
if (isJunkLocation(location)) {
log.debug("rejecting junk location {s}", .{location});
res.status = 404;
res.content_type = .TEXT;
res.body = "Not found\n";
return;
} }
try handleWeatherInternal(opts, req, res, location, client_ip); try handleWeatherInternal(opts, req, res, location, client_ip);
@ -129,11 +117,6 @@ fn handleWeatherInternal(
res.body = "Location not found (location query resolution failure)\n"; res.body = "Location not found (location query resolution failure)\n";
return; return;
}, },
error.GeocodingFailed => {
log.warn("geocoding unavailable for query {s}", .{location_query});
upstreamUnavailable(res, "Location lookup is temporarily unavailable");
return;
},
else => return err, else => return err,
} }
}; };
@ -147,23 +130,11 @@ fn handleWeatherInternal(
res.body = "Location not found (provider fetch failure)\n"; res.body = "Location not found (provider fetch failure)\n";
return; return;
}, },
error.OutOfMemory => return err, else => return err,
// Anything else is the upstream failing (status, network, TLS, or
// a malformed response) with no usable cached data to fall back on
else => {
log.warn("weather unavailable for {d:.4},{d:.4}: {}", .{ location.coords.latitude, location.coords.longitude, err });
upstreamUnavailable(res, "Weather data is temporarily unavailable");
return;
},
} }
}; };
defer weather.deinit(); defer weather.deinit();
// The upstream is down and this is old data. Say so, with how long past its
// expiry it is, so scripts and status bars can decide whether to trust it.
if (weather.stale_for_s) |stale_for_s|
res.headers.add("X-Weather-Stale", try std.fmt.allocPrint(res.arena, "{d}", .{stale_for_s}));
// Set display name for rendering // Set display name for rendering
weather.display_name = try req_alloc.dupe(u8, location.name); weather.display_name = try req_alloc.dupe(u8, location.name);
@ -213,45 +184,6 @@ fn handleWeatherInternal(
try renderWeatherData(res.writer(), weather, params, render_options, now_unix_s); try renderWeatherData(res.writer(), weather, params, render_options, now_unix_s);
} }
/// A dependency we cannot work without is down. That is a 503 the client can
/// retry, not a 500 implying we are broken.
fn upstreamUnavailable(res: *httpz.Response, comptime message: []const u8) void {
res.status = 503;
res.content_type = .TEXT;
res.header("Retry-After", "60");
res.body = message ++ ", please try again shortly\n";
}
/// File extensions that only ever show up in requests from scanners probing
/// for files. No place name ends in one, and letting them through costs a
/// Nominatim lookup each time (failures are not cached) plus possibly a
/// weather fetch.
const junk_extensions = [_][]const u8{
"7z", "asp", "aspx", "bak", "bz2", "cfg", "cgi", "conf",
"config", "css", "env", "gif", "gz", "htm", "html", "ico",
"ini", "jpeg", "jpg", "js", "json", "jsp", "log", "php",
"png", "rar", "sql", "svg", "tar", "tgz", "txt", "webp",
"xml", "yaml", "yml", "zip",
};
/// Whether a location is a probe for something other than weather. Runs after
/// the endpoints we do serve (favicon.ico, robots.txt, ...) are handled.
fn isJunkLocation(location: []const u8) bool {
// `@domain` lookups legitimately end in what looks like an extension
if (location.len == 0 or location[0] == '@') return false;
const name = if (location[0] == '~') location[1..] else location;
if (name.len == 0) return true;
// Dotfiles: .env, .git, .aws, ...
if (name[0] == '.') return true;
// WordPress probes: wp-login.php is caught below, wp-admin is not
if (std.ascii.startsWithIgnoreCase(name, "wp-")) return true;
const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse return false;
const extension = name[dot + 1 ..];
for (junk_extensions) |junk|
if (std.ascii.eqlIgnoreCase(extension, junk)) return true;
return false;
}
fn renderWeatherData( fn renderWeatherData(
writer: *std.Io.Writer, writer: *std.Io.Writer,
weather: types.WeatherData, weather: types.WeatherData,
@ -692,152 +624,3 @@ test "handler: an explicit unit request still overrides the pin's country" {
try ht.expectStatus(200); try ht.expectStatus(200);
try ht.expectBody("☀️ +20°C\n"); try ht.expectBody("☀️ +20°C\n");
} }
test "isJunkLocation: rejects scanner probes" {
const junk = [_][]const u8{
"sitemap.xml.gz", "wp-login.php", "wp-admin", "WP-Admin", ".env",
".git", "config.json", "backup.sql", "index.HTML", "apple-touch-icon.png",
"ads.txt", "~", "~shell.php",
};
for (junk) |location| {
std.testing.expect(isJunkLocation(location)) catch |err| {
std.debug.print("expected junk: {s}\n", .{location});
return err;
};
}
}
test "isJunkLocation: lets real locations through" {
const real = [_][]const u8{
"London", "St. Louis", "St.Louis", "Washington.DC", "48.85,2.35",
"50.39.107.54", "2001:db8::1", "@github.com", "@wttr.in", "~Eiffel Tower",
"Mt. Hood",
"Москва",
"PDX", "",
};
for (real) |location| {
std.testing.expect(!isJunkLocation(location)) catch |err| {
std.debug.print("expected real: {s}\n", .{location});
return err;
};
}
}
test "handler: junk paths and unknown endpoints are 404 without a lookup" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
// Any lookup reaching the provider would fail loudly with a 503 instead
harness.mock.fail_with = error.WeatherApiFailed;
const cases = [_][]const u8{ "sitemap.xml.gz", ":", ":bogus", ".env", "wp-admin" };
for (cases) |location| {
var ht = httpz.testing.init(.{});
defer ht.deinit();
const url = try std.fmt.allocPrint(allocator, "/{s}", .{location});
defer allocator.free(url);
ht.url(url);
ht.param("location", location);
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
try ht.expectStatus(404);
}
}
test "handler: upstream weather failure with nothing cached is a retryable 503" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
harness.mock.fail_with = error.WeatherApiFailed;
// The harness cache directory is shared across tests and runs, so other
// tests' responses for these coordinates would be served from it. An empty
// in-memory cache guarantees there is nothing to fall back on.
const Cache = @import("../cache/Cache.zig");
const empty_cache = try Cache.init(allocator, std.testing.io, .{
.cache_dir = null,
.stale_retention_s = WeatherProvider.stale_retention_s,
});
defer empty_cache.deinit();
harness.opts.provider = harness.mock.provider(empty_cache);
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=1");
ht.param("location", "73.158.64.1");
try handleWeather(&harness.opts, ht.req, ht.res, "73.158.64.1");
try ht.expectStatus(503);
try ht.expectHeader("Retry-After", "60");
}
test "handler: stale data is served with an X-Weather-Stale header" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
const Pins = @import("../location/Pins.zig");
const Location = @import("../location/resolver.zig").Location;
const Cache = @import("../cache/Cache.zig");
var harness = try MockHarness.init(allocator);
defer harness.deinit();
harness.mock.fail_with = error.WeatherApiFailed;
// Pinned so the cache key does not depend on the GeoLite2 database
try harness.geoip.pins.put(
try Pins.parseCidr("203.0.113.0/24"),
"Seattle, Washington, United States",
.{ .latitude = 47.6, .longitude = -122.3 },
Location.isoFrom("US"),
);
// Own cache, for the same reason as the 503 test above, holding an entry
// for the pinned coordinates that expired five minutes ago
const cache = try Cache.init(allocator, std.testing.io, .{
.cache_dir = null,
.stale_retention_s = WeatherProvider.stale_retention_s,
});
defer cache.deinit();
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
try cache.lru.put("47.6000,-122.3000", "{}", now - 5 * 60 * 1000);
harness.opts.provider = harness.mock.provider(cache);
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/203.0.113.7?format=1");
ht.param("location", "203.0.113.7");
try handleWeather(&harness.opts, ht.req, ht.res, "203.0.113.7");
try ht.expectStatus(200);
try ht.expectBody("☀️ +68°F\n");
const pr = try ht.parseResponse();
const stale_for_s = try std.fmt.parseInt(i64, pr.headers.get("X-Weather-Stale").?, 10);
try std.testing.expect(stale_for_s >= 300 and stale_for_s < 360);
}
test "handler: fresh data has no X-Weather-Stale header" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=1");
ht.param("location", "73.158.64.1");
try handleWeather(&harness.opts, ht.req, ht.res, "73.158.64.1");
try ht.expectStatus(200);
const pr = try ht.parseResponse();
try std.testing.expect(pr.headers.get("X-Weather-Stale") == null);
}

View file

@ -2,7 +2,6 @@ const std = @import("std");
const Coordinates = @import("../Coordinates.zig"); const Coordinates = @import("../Coordinates.zig");
const Location = @import("resolver.zig").Location; const Location = @import("resolver.zig").Location;
const srf = @import("srf"); const srf = @import("srf");
const Lru = @import("../cache/Lru.zig");
const GeoCache = @This(); const GeoCache = @This();
@ -15,17 +14,6 @@ cache: std.StringHashMap(CachedLocation),
cache_file: ?[]const u8, cache_file: ?[]const u8,
dirty: bool, dirty: bool,
last_save: i64, last_save: i64,
/// Queries the geocoder answered with no results. Kept apart from `cache`, and
/// in memory only with a cap and a TTL, because real places are a bounded set
/// but nonsense queries are unbounded and chosen by whoever is scanning us.
not_found: Lru,
/// Request handlers share one GeoCache across httpz's thread pool. Exclusive
/// rather than read/write because `get` may rewrite the file and `not_found`
/// lookups mutate (see `Lru`).
mutex: std.Io.Mutex = .init,
const not_found_max_entries = 1_000;
const not_found_ttl_ms = 24 * std.time.ms_per_hour;
pub const CachedLocation = struct { pub const CachedLocation = struct {
name: []const u8, name: []const u8,
@ -53,7 +41,6 @@ pub fn init(allocator: std.mem.Allocator, io: std.Io, cache_file: ?[]const u8) !
.cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null, .cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null,
.dirty = false, .dirty = false,
.last_save = std.Io.Timestamp.now(io, .real).toMilliseconds(), .last_save = std.Io.Timestamp.now(io, .real).toMilliseconds(),
.not_found = try Lru.init(allocator, io, not_found_max_entries),
}; };
} }
@ -71,75 +58,27 @@ pub fn deinit(self: *GeoCache) void {
self.allocator.free(entry.value_ptr.name); self.allocator.free(entry.value_ptr.name);
} }
self.cache.deinit(); self.cache.deinit();
self.not_found.deinit();
if (self.cache_file) |f| self.allocator.free(f); if (self.cache_file) |f| self.allocator.free(f);
} }
/// Returns a copy of the cached location, whose `name` the caller owns and pub fn get(self: *GeoCache, query: []const u8) ?CachedLocation {
/// must free with `allocator`. A copy because another thread's `put` can free
/// the cached name the moment the lock is released.
pub fn get(self: *GeoCache, allocator: std.mem.Allocator, query: []const u8) !?CachedLocation {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
self.saveIfNeeded(); self.saveIfNeeded();
const cached = self.cache.get(query) orelse return null; return self.cache.get(query);
return .{
.name = try allocator.dupe(u8, cached.name),
.coords = cached.coords,
.iso_country = cached.iso_country,
};
} }
pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void { pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void {
self.mutex.lockUncancelable(self.io); const key = try self.allocator.dupe(u8, query);
defer self.mutex.unlock(self.io); const value = CachedLocation{
.name = try self.allocator.dupe(u8, location.name),
const name = try self.allocator.dupe(u8, location.name);
errdefer self.allocator.free(name);
const gop = try self.cache.getOrPut(query);
if (gop.found_existing) {
// Keep the stored key; replacing the value used to leak both the old
// name and a fresh copy of the key
self.allocator.free(gop.value_ptr.name);
} else {
// `getOrPut` stored the caller's slice; swap in one we own
gop.key_ptr.* = self.allocator.dupe(u8, query) catch |err| {
self.cache.removeByPtr(gop.key_ptr);
return err;
};
}
gop.value_ptr.* = .{
.name = name,
.coords = location.coords, .coords = location.coords,
.iso_country = location.iso_country, .iso_country = location.iso_country,
}; };
try self.cache.put(key, value);
self.dirty = true; self.dirty = true;
} }
/// Records that the geocoder has no result for `query`. Only for a definitive /// Save cache to disk if dirty and enough time has passed (15 minutes)
/// "no such place": a geocoder that failed or was unreachable says nothing pub fn saveIfNeeded(self: *GeoCache) void {
/// about the query, and caching that would hide real places.
pub fn putNotFound(self: *GeoCache, query: []const u8) !void {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
try self.not_found.put(query, "", now + not_found_ttl_ms);
}
/// Whether the geocoder recently had no result for `query`.
pub fn isKnownNotFound(self: *GeoCache, query: []const u8) bool {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
return self.not_found.get(query) != null;
}
/// Save cache to disk if dirty and enough time has passed (15 minutes).
/// Caller must hold `mutex`.
fn saveIfNeeded(self: *GeoCache) void {
if (!self.dirty) return; if (!self.dirty) return;
const cache_file = self.cache_file orelse return; const cache_file = self.cache_file orelse return;
@ -269,31 +208,10 @@ test "GeoCache basic operations" {
}, },
}); });
const result = (try cache.get(allocator, "London")) orelse return error.TestUnexpectedResult; const result = cache.get("London");
defer allocator.free(result.name); try std.testing.expect(result != null);
try std.testing.expectEqualStrings("London, UK", result.name); try std.testing.expectApproxEqAbs(@as(f64, 51.5074), result.?.coords.latitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 51.5074), result.coords.latitude, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, -0.1278), result.?.coords.longitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, -0.1278), result.coords.longitude, 0.0001);
}
test "GeoCache replacing an entry frees the old one and keeps returned copies valid" {
// std.testing.allocator fails the test on a leak, which is half the assertion
const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit();
try cache.put("Portland", .{ .name = "Portland, Maine", .coords = .{ .latitude = 43.66, .longitude = -70.26 } });
const first = (try cache.get(allocator, "Portland")).?;
defer allocator.free(first.name);
try cache.put("Portland", .{ .name = "Portland, Oregon", .coords = .{ .latitude = 45.52, .longitude = -122.68 } });
const second = (try cache.get(allocator, "Portland")).?;
defer allocator.free(second.name);
try std.testing.expectEqual(@as(u32, 1), cache.cache.count());
try std.testing.expectEqualStrings("Portland, Oregon", second.name);
// The first copy is unaffected by the entry it came from being replaced
try std.testing.expectEqualStrings("Portland, Maine", first.name);
} }
test "GeoCache miss returns null" { test "GeoCache miss returns null" {
@ -301,39 +219,8 @@ test "GeoCache miss returns null" {
var cache = try GeoCache.init(allocator, std.testing.io, null); var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit(); defer cache.deinit();
try std.testing.expect(try cache.get(allocator, "NonExistent") == null); const result = cache.get("NonExistent");
} try std.testing.expect(result == null);
test "GeoCache remembers queries with no result, separately and for a limited time" {
const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit();
try std.testing.expect(!cache.isKnownNotFound("Nowhereville"));
try cache.putNotFound("Nowhereville");
try std.testing.expect(cache.isKnownNotFound("Nowhereville"));
try std.testing.expect(!cache.isKnownNotFound("Somewhereville"));
// Not a place, so not in the positive cache and never written to disk
try std.testing.expect(try cache.get(allocator, "Nowhereville") == null);
try std.testing.expectEqual(@as(u32, 0), cache.cache.count());
try std.testing.expect(!cache.dirty);
// Expired entries are forgotten, so the place gets another chance
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
try cache.not_found.put("Gonesville", "", now - 1000);
try std.testing.expect(!cache.isKnownNotFound("Gonesville"));
}
test "GeoCache bounds the not-found set" {
const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit();
var buf: [32]u8 = undefined;
for (0..not_found_max_entries * 2) |i|
try cache.putNotFound(try std.fmt.bufPrint(&buf, "junk-{d}", .{i}));
try std.testing.expect(cache.not_found.map.count() <= not_found_max_entries);
} }
test "save produces SRF an operator can read" { test "save produces SRF an operator can read" {

View file

@ -194,10 +194,6 @@ pub const Cache = struct {
path: []const u8, path: []const u8,
entries: std.AutoHashMap(u128, Location), entries: std.AutoHashMap(u128, Location),
file: ?std.Io.File, file: ?std.Io.File,
/// Request handlers reach this from httpz's thread pool, outside GeoIp's
/// lock. Guards `entries` and the appends to `file`, where two unguarded
/// writers would read the same end offset and overwrite each other.
mutex: std.Io.Mutex = .init,
/// On-disk shape of one entry. /// On-disk shape of one entry.
/// ///
@ -312,9 +308,6 @@ pub const Cache = struct {
} }
pub fn get(self: *Cache, ip: u128) ?Location { pub fn get(self: *Cache, ip: u128) ?Location {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.entries.get(ip) orelse return null; const entry = self.entries.get(ip) orelse return null;
return .{ return .{
.allocator = self.allocator, .allocator = self.allocator,
@ -329,17 +322,11 @@ pub const Cache = struct {
/// Unit selection only needs the country, and doing it this way keeps that /// Unit selection only needs the country, and doing it this way keeps that
/// path free of both an allocation and any chance of a network request. /// path free of both an allocation and any chance of a network request.
pub fn getCountry(self: *Cache, ip: u128) ?[2]u8 { pub fn getCountry(self: *Cache, ip: u128) ?[2]u8 {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const entry = self.entries.getPtr(ip) orelse return null; const entry = self.entries.getPtr(ip) orelse return null;
return entry.iso_country; return entry.iso_country;
} }
pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void { pub fn put(self: *Cache, ip: u128, family: u8, loc: Location) !void {
self.mutex.lockUncancelable(self.io);
defer self.mutex.unlock(self.io);
const name_copy = try self.allocator.dupe(u8, loc.name); const name_copy = try self.allocator.dupe(u8, loc.name);
errdefer self.allocator.free(name_copy); errdefer self.allocator.free(name_copy);

View file

@ -183,18 +183,14 @@ pub const Resolver = struct {
// Don't bother passing this through // Don't bother passing this through
if (std.mem.eql(u8, name, "favicon.ico")) return error.GeocodingFailed; if (std.mem.eql(u8, name, "favicon.ico")) return error.GeocodingFailed;
// Check cache first // Check cache first
if (try self.geocache.get(self.allocator, name)) |cached| { if (self.geocache.get(name)) |cached| {
return Location{ return Location{
.allocator = self.allocator, .allocator = self.allocator,
.name = cached.name, .name = try self.allocator.dupe(u8, cached.name),
.coords = cached.coords, .coords = cached.coords,
.iso_country = cached.iso_country, .iso_country = cached.iso_country,
}; };
} }
if (self.geocache.isKnownNotFound(name)) {
log.debug("nominatim recently found nothing for {s}; not asking again", .{name});
return error.LocationNotFound;
}
log.info("Calling nominatim (OpenStreetMap) to resolve place name {s} to coordinates", .{name}); log.info("Calling nominatim (OpenStreetMap) to resolve place name {s} to coordinates", .{name});
if (@import("builtin").is_test) return error.GeocodingUnavailableInUnitTest; if (@import("builtin").is_test) return error.GeocodingUnavailableInUnitTest;
@ -243,8 +239,6 @@ pub const Resolver = struct {
defer parsed.deinit(); defer parsed.deinit();
if (parsed.value.array.items.len == 0) { if (parsed.value.array.items.len == 0) {
log.info("nominatim found nothing for {s}", .{name});
try self.geocache.putNotFound(name);
return error.LocationNotFound; return error.LocationNotFound;
} }
@ -414,22 +408,6 @@ test "resolver init" {
try std.testing.expect(resolver.airports == null); try std.testing.expect(resolver.airports == null);
} }
test "resolver answers a recent not-found from the cache without calling nominatim" {
const allocator = std.testing.allocator;
var geocache = try GeoCache.init(allocator, std.testing.io, null);
defer geocache.deinit();
var resolver = Resolver.init(allocator, std.testing.io, null, &geocache, null);
// Without the entry the lookup reaches the network path, which unit tests
// stub out with a distinct error
try std.testing.expectError(error.GeocodingUnavailableInUnitTest, resolver.resolve("Nowhereville"));
try geocache.putNotFound("Nowhereville");
try std.testing.expectError(error.LocationNotFound, resolver.resolve("Nowhereville"));
// Also via the `~` special-location form, which geocodes the same name
try std.testing.expectError(error.LocationNotFound, resolver.resolve("~Nowhereville"));
}
test "resolve IP address with GeoIP" { test "resolve IP address with GeoIP" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");

View file

@ -2,7 +2,6 @@ const std = @import("std");
const Config = @import("Config.zig"); const Config = @import("Config.zig");
const Cache = @import("cache/Cache.zig"); const Cache = @import("cache/Cache.zig");
const MetNo = @import("weather/MetNo.zig"); const MetNo = @import("weather/MetNo.zig");
const WeatherProvider = @import("weather/Provider.zig");
const Server = @import("http/Server.zig"); const Server = @import("http/Server.zig");
const RateLimiter = @import("http/RateLimiter.zig"); const RateLimiter = @import("http/RateLimiter.zig");
const GeoIp = @import("location/GeoIp.zig"); const GeoIp = @import("location/GeoIp.zig");
@ -105,7 +104,6 @@ fn serve(
const cache = try Cache.init(allocator, io, .{ const cache = try Cache.init(allocator, io, .{
.max_entries = cfg.cache_size, .max_entries = cfg.cache_size,
.cache_dir = cfg.cache_dir, .cache_dir = cfg.cache_dir,
.stale_retention_s = WeatherProvider.stale_retention_s,
}); });
defer cache.deinit(); defer cache.deinit();

View file

@ -147,14 +147,6 @@ fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates)
}); });
if (result.status != .ok) { if (result.status != .ok) {
const body = response_buf[0..writer.end];
std.log.warn("met.no returned HTTP {d} ({s}) for {s} with User-Agent '{s}': {s}", .{
@intFromEnum(result.status),
result.status.phrase() orelse "unknown",
url,
user_agent,
body[0..@min(body.len, 512)],
});
return error.WeatherApiFailed; return error.WeatherApiFailed;
} }

View file

@ -9,8 +9,6 @@ const Mock = @This();
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
responses: std.StringHashMap([]const u8), responses: std.StringHashMap([]const u8),
parse_fn: ?*const fn (ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) anyerror!types.WeatherData = null, parse_fn: ?*const fn (ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) anyerror!types.WeatherData = null,
/// When set, every fetch fails with this error, simulating an upstream outage.
fail_with: ?anyerror = null,
pub fn init(allocator: std.mem.Allocator) !Mock { pub fn init(allocator: std.mem.Allocator) !Mock {
return Mock{ return Mock{
@ -39,7 +37,6 @@ pub fn addResponse(self: *Mock, coords: Coordinates, raw_json: []const u8) !void
fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) ![]const u8 { fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) ![]const u8 {
const self: *Mock = @ptrCast(@alignCast(ptr)); const self: *Mock = @ptrCast(@alignCast(ptr));
if (self.fail_with) |err| return err;
const key = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }); const key = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
defer allocator.free(key); defer allocator.free(key);

View file

@ -11,14 +11,6 @@ const Cache = @import("../cache/Cache.zig");
const WeatherProvider = @This(); const WeatherProvider = @This();
const log = std.log.scoped(.weather);
/// How long past its TTL a cached response may still be served when the
/// upstream fails. With the 16-33 minute TTL below, the oldest data ever served
/// is about two hours old; beyond that "current" conditions are fiction.
/// Configure the weather cache with this as `Cache.Config.stale_retention_s`.
pub const stale_retention_s: u64 = 90 * 60;
ptr: *anyopaque, ptr: *anyopaque,
vtable: *const VTable, vtable: *const VTable,
cache: *Cache, cache: *Cache,
@ -35,29 +27,11 @@ pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordi
const cache_key = try std.fmt.bufPrint(&buf, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }); const cache_key = try std.fmt.bufPrint(&buf, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
// Check cache first // Check cache first
if (try self.cache.get(allocator, cache_key)) |cached_raw| { if (self.cache.get(cache_key)) |cached_raw|
defer allocator.free(cached_raw);
return self.vtable.parse(self.ptr, allocator, cached_raw); return self.vtable.parse(self.ptr, allocator, cached_raw);
}
// Cache miss - fetch raw data from provider, falling back to an expired // Cache miss - fetch raw data from provider
// entry if the upstream is down const raw = try self.vtable.fetchRaw(self.ptr, allocator, coords);
const raw = self.vtable.fetchRaw(self.ptr, allocator, coords) catch |err| {
if (try self.cache.getStale(allocator, cache_key)) |stale| {
defer allocator.free(stale.value);
const now_ms = std.Io.Timestamp.now(self.cache.io, .real).toMilliseconds();
const stale_for_s = @divTrunc(now_ms - stale.expires, std.time.ms_per_s);
log.warn("weather fetch for {s} failed ({}); serving cached data that expired {d}s ago", .{
cache_key,
err,
stale_for_s,
});
var weather = try self.vtable.parse(self.ptr, allocator, stale.value);
weather.stale_for_s = stale_for_s;
return weather;
}
return err;
};
defer allocator.free(raw); defer allocator.free(raw);
// TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd. // TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd.
@ -104,65 +78,4 @@ test "provider fetch" {
try std.testing.expect(weather1.forecast.len > 0); try std.testing.expect(weather1.forecast.len > 0);
try std.testing.expect(weather2.forecast.len > 0); try std.testing.expect(weather2.forecast.len > 0);
try std.testing.expect(weather1.stale_for_s == null);
try std.testing.expect(weather2.stale_for_s == null);
}
test "provider serves expired cached data when the upstream fails" {
const Mock = @import("Mock.zig");
const MetNo = @import("MetNo.zig");
const cache = try Cache.init(std.testing.allocator, std.testing.io, .{
.max_entries = 10,
.cache_dir = null,
.stale_retention_s = stale_retention_s,
});
defer cache.deinit();
var mock = try Mock.init(std.testing.allocator);
defer mock.deinit();
mock.parse_fn = MetNo.parse;
mock.fail_with = error.WeatherApiFailed;
const provider = mock.provider(cache);
const coords = Coordinates{ .latitude = 33.4484, .longitude = -112.0740 };
// Nothing cached: the upstream error surfaces unchanged
try std.testing.expectError(error.WeatherApiFailed, provider.fetch(std.testing.allocator, coords));
// A response that expired ten minutes ago is better than nothing
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
try cache.lru.put("33.4484,-112.0740", @embedFile("../tests/metno_test_data.json"), now - 10 * 60 * 1000);
const weather = try provider.fetch(std.testing.allocator, coords);
defer weather.deinit();
try std.testing.expect(weather.forecast.len > 0);
// Marked stale, by about the ten minutes it has been expired
const stale_for_s = weather.stale_for_s.?;
try std.testing.expect(stale_for_s >= 600 and stale_for_s < 660);
}
test "provider does not serve data past the stale retention window" {
const Mock = @import("Mock.zig");
const MetNo = @import("MetNo.zig");
const cache = try Cache.init(std.testing.allocator, std.testing.io, .{
.max_entries = 10,
.cache_dir = null,
.stale_retention_s = stale_retention_s,
});
defer cache.deinit();
var mock = try Mock.init(std.testing.allocator);
defer mock.deinit();
mock.parse_fn = MetNo.parse;
mock.fail_with = error.WeatherApiFailed;
const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
const too_old = now - @as(i64, @intCast(stale_retention_s * std.time.ms_per_s)) - 1000;
try cache.lru.put("33.4484,-112.0740", @embedFile("../tests/metno_test_data.json"), too_old);
const provider = mock.provider(cache);
const coords = Coordinates{ .latitude = 33.4484, .longitude = -112.0740 };
try std.testing.expectError(error.WeatherApiFailed, provider.fetch(std.testing.allocator, coords));
} }

View file

@ -91,9 +91,6 @@ pub const WeatherData = struct {
current: CurrentCondition, current: CurrentCondition,
forecast: []ForecastDay, forecast: []ForecastDay,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Set when the upstream was unavailable and this came from an expired
/// cache entry: seconds since that entry expired. Null for fresh data.
stale_for_s: ?i64 = null,
pub fn deinit(self: WeatherData) void { pub fn deinit(self: WeatherData) void {
self.allocator.free(self.location); self.allocator.free(self.location);