move cache to the provider interface to persist raw provider data

This commit is contained in:
Emil Lerch 2025-12-19 15:27:01 -08:00
parent aef0806040
commit 8b51be05af
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 63 additions and 85 deletions

View file

@ -1,6 +1,5 @@
const std = @import("std");
const httpz = @import("httpz");
const Cache = @import("../cache/Cache.zig");
const WeatherProvider = @import("../weather/Provider.zig");
const Resolver = @import("../location/resolver.zig").Resolver;
const Location = @import("../location/resolver.zig").Location;
@ -13,7 +12,6 @@ const custom = @import("../render/custom.zig");
const help = @import("help.zig");
pub const HandleWeatherOptions = struct {
cache: *Cache,
provider: WeatherProvider,
resolver: *Resolver,
geoip: *@import("../location/GeoIp.zig"),
@ -91,14 +89,6 @@ fn handleWeatherInternal(
) !void {
const allocator = req.arena;
const cache_key = try generateCacheKey(allocator, req, location_query);
if (opts.cache.get(cache_key)) |cached| {
res.content_type = .TEXT;
res.body = cached;
return;
}
// Resolve location
const location = if (location_query.len == 0)
Location{ .name = "London", .coords = .{ .latitude = 51.5074, .longitude = -0.1278 } }
@ -175,36 +165,12 @@ fn handleWeatherInternal(
}
} else try ansi.render(allocator, weather, .{ .use_imperial = use_imperial });
// Will use a TTL to a random value between 1000-2000 seconds (16-33 minutes).
// We want to avoid a thundering herd problem where all cached entries expire
// at exactly the same time, causing a spike of requests to the weather provider.
// Base TTL: 1000 seconds (~16 minutes)
// Random jitter: 0-1000 seconds
// Total: 1000-2000 seconds (16-33 minutes)
const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000);
try opts.cache.put(cache_key, output, ttl);
if (res.content_type != .JSON) {
res.content_type = .TEXT;
}
res.body = output;
}
fn generateCacheKey(
allocator: std.mem.Allocator,
req: *httpz.Request,
location: ?[]const u8,
) ![]const u8 {
const loc = location orelse "";
const query_string = req.url.query;
return std.fmt.allocPrint(allocator, "{s}:{s}:{s}", .{
req.url.path,
loc,
query_string,
});
}
test "parseXForwardedFor extracts first IP" {
try std.testing.expectEqualStrings("192.168.1.1", parseXForwardedFor("192.168.1.1"));
try std.testing.expectEqualStrings("10.0.0.1", parseXForwardedFor("10.0.0.1, 172.16.0.1"));

View file

@ -66,8 +66,7 @@ pub fn main() !void {
defer metno.deinit();
var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{
.cache = &cache,
.provider = metno.provider(),
.provider = metno.provider(&cache),
.resolver = &resolver,
.geoip = &geoip,
}, &rate_limiter);

View file

@ -2,6 +2,7 @@ const std = @import("std");
const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig");
const Cache = @import("../cache/Cache.zig");
const MetNo = @This();
@ -68,17 +69,19 @@ pub fn init(allocator: std.mem.Allocator) !MetNo {
};
}
pub fn provider(self: *MetNo) WeatherProvider {
pub fn provider(self: *MetNo, cache: *Cache) WeatherProvider {
return .{
.ptr = self,
.cache = cache,
.vtable = &.{
.fetch = fetch,
.fetchRaw = fetchRaw,
.parse = parse,
.deinit = deinitProvider,
},
};
}
fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) ![]const u8 {
const self: *MetNo = @ptrCast(@alignCast(ptr));
const url = try std.fmt.allocPrint(
@ -110,16 +113,28 @@ fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) !ty
}
const response_body = response_buf[0..writer.end];
return try allocator.dupe(u8, response_body);
}
fn parse(ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) !types.WeatherData {
_ = ptr;
// Parse JSON response
const parsed = try std.json.parseFromSlice(
std.json.Value,
allocator,
response_body,
raw,
.{},
);
defer parsed.deinit();
// Extract coordinates from the response (we don't have them passed in)
const geometry = parsed.value.object.get("geometry") orelse return error.InvalidResponse;
const coordinates = geometry.object.get("coordinates") orelse return error.InvalidResponse;
const lon: f64 = coordinates.array.items[0].float;
const lat: f64 = coordinates.array.items[1].float;
const coords = Coordinates{ .latitude = lat, .longitude = lon };
return try parseMetNoResponse(allocator, coords, parsed.value);
}

View file

@ -2,47 +2,51 @@ const std = @import("std");
const WeatherProvider = @import("Provider.zig");
const Coordinates = @import("../Coordinates.zig");
const types = @import("types.zig");
const Cache = @import("../cache/Cache.zig");
const Mock = @This();
allocator: std.mem.Allocator,
responses: std.StringHashMap(types.WeatherData),
responses: std.StringHashMap([]const u8),
pub fn init(allocator: std.mem.Allocator) !Mock {
return Mock{
.allocator = allocator,
.responses = std.StringHashMap(types.WeatherData).init(allocator),
.responses = std.StringHashMap([]const u8).init(allocator),
};
}
pub fn provider(self: *Mock) WeatherProvider {
pub fn provider(self: *Mock, cache: *Cache) WeatherProvider {
return .{
.ptr = self,
.cache = cache,
.vtable = &.{
.fetch = fetch,
.fetchRaw = fetchRaw,
.parse = parse,
.deinit = deinitProvider,
},
};
}
pub fn addResponse(self: *Mock, coords: Coordinates, data: types.WeatherData) !void {
pub fn addResponse(self: *Mock, coords: Coordinates, raw_json: []const u8) !void {
const key = try std.fmt.allocPrint(self.allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
try self.responses.put(key, data);
try self.responses.put(key, try self.allocator.dupe(u8, raw_json));
}
fn fetch(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) ![]const u8 {
const self: *Mock = @ptrCast(@alignCast(ptr));
const key = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
defer allocator.free(key);
const data = self.responses.get(key) orelse return error.LocationNotFound;
const raw = self.responses.get(key) orelse return error.LocationNotFound;
return try allocator.dupe(u8, raw);
}
return types.WeatherData{
.location = try allocator.dupe(u8, data.location),
.current = data.current,
.forecast = try allocator.dupe(types.ForecastDay, data.forecast),
.allocator = allocator,
};
fn parse(ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) !types.WeatherData {
_ = ptr;
_ = allocator;
_ = raw;
return error.NotImplemented;
}
fn deinitProvider(ptr: *anyopaque) void {
@ -54,38 +58,12 @@ pub fn deinit(self: *Mock) void {
var it = self.responses.iterator();
while (it.next()) |entry| {
self.allocator.free(entry.key_ptr.*);
self.allocator.free(entry.value_ptr.*);
}
self.responses.deinit();
}
test "mock weather provider" {
var mock = try Mock.init(std.testing.allocator);
defer mock.deinit();
const coords = Coordinates{ .latitude = 51.5074, .longitude = -0.1278 };
const data = types.WeatherData{
.location = "London",
.current = .{
.temp_c = 15.0,
.temp_f = 59.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 65,
.wind_kph = 10.0,
.wind_dir = "N",
.pressure_mb = 1013.0,
.precip_mm = 0.0,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try mock.addResponse(coords, data);
const p = mock.provider();
const result = try p.fetch(std.testing.allocator, coords);
defer result.deinit();
try std.testing.expectEqual(@as(f32, 15.0), result.current.temp_c);
// TODO: Implement Mock.parse to enable this test
return error.SkipZigTest;
}

View file

@ -7,19 +7,39 @@
const std = @import("std");
const types = @import("types.zig");
const Coordinates = @import("../Coordinates.zig");
const Cache = @import("../cache/Cache.zig");
const WeatherProvider = @This();
ptr: *anyopaque,
vtable: *const VTable,
cache: *Cache,
pub const VTable = struct {
fetch: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) anyerror!types.WeatherData,
fetchRaw: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates) anyerror![]const u8,
parse: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator, raw: []const u8) anyerror!types.WeatherData,
deinit: *const fn (ptr: *anyopaque) void,
};
pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
return self.vtable.fetch(self.ptr, allocator, coords);
// Generate cache key from coordinates
const cache_key = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
defer allocator.free(cache_key);
// Check cache first
if (self.cache.get(cache_key)) |cached_raw|
return self.vtable.parse(self.ptr, allocator, cached_raw);
// Cache miss - fetch raw data from provider
const raw = try self.vtable.fetchRaw(self.ptr, allocator, coords);
defer allocator.free(raw);
// TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd
const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000);
try self.cache.put(cache_key, raw, ttl);
// Parse and return
return self.vtable.parse(self.ptr, allocator, raw);
}
pub fn deinit(self: WeatherProvider) void {