Compare commits

..

No commits in common. "ea88a88e316d70a72a6fd4be1935626ab1e71d3e" and "f0b382375e9e14b7783e21c743bc44c4c1bcd878" have entirely different histories.

4 changed files with 33 additions and 87 deletions

View file

@ -119,26 +119,28 @@ pub const RateLimitConfig = struct {
### Cache ### Cache
**Single layer, two-tier cache system (L1 memory + L2 file):** **Single-layer cache with two storage backends:**
**Interface:** **Interface:**
```zig ```zig
pub const Cache = struct { pub const Cache = struct {
lru: Lru, // L1: In-memory cache lru: LRU,
cache_dir: ?[]const u8, // L2: Optional file-backed storage file_store: FileStore,
pub fn get(self: *Cache, key: []const u8) ?[]const u8; pub fn get(self: *Cache, key: []const u8) ?[]const u8;
pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl: u64) !void; pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl: u64) !void;
}; };
``` ```
**Cache Key:**
```
{user_agent}:{path}:{query}:{client_ip}
```
**Storage Strategy:** **Storage Strategy:**
- **L1 (Memory)**: LRU cache with configurable max entries - Small responses (<1KB): In-memory LRU only
- **L2 (Disk)**: Optional file-backed storage for persistence - Large responses (≥1KB): LRU stores file path, data on disk
- Files stored as JSON with key, value, and expiration timestamp - TTL: 1000-2000s (randomized to prevent thundering herd)
- On eviction from L1, data remains in L2
- On cache miss in L1, checks L2 and promotes to L1 if found
- **TTL**: 1000-2000s (randomized to prevent thundering herd)
**Cache Locations:** **Cache Locations:**
@ -146,32 +148,23 @@ All caches default to `$XDG_CACHE_HOME/wttr` (typically `~/.cache/wttr`).
1. **Weather Response Cache** 1. **Weather Response Cache**
- Location: `$WTTR_CACHE_DIR` (default: `~/.cache/wttr/`) - Location: `$WTTR_CACHE_DIR` (default: `~/.cache/wttr/`)
- Format: JSON (one file for each entry/location)
- Size: 10,000 entries (configurable via `WTTR_CACHE_SIZE`) - Size: 10,000 entries (configurable via `WTTR_CACHE_SIZE`)
- Expiration: 1000-2000 seconds (randomized) - Expiration: 1000-2000 seconds (randomized)
- Eviction: LRU - Eviction: LRU
- Implementation: `src/cache/Cache.zig`, `src/cache/Lru.zig`
2. **Geocoding Cache** 2. **Geocoding Cache**
- Purpose: Caches results from nominatim API to minimize API calls
- Location: `$WTTR_GEOCACHE_FILE` (default: `~/.cache/wttr/geocache.json`) - Location: `$WTTR_GEOCACHE_FILE` (default: `~/.cache/wttr/geocache.json`)
- Format: JSON (single file with all entries) - Format: JSON
- Expiration: None (persists indefinitely) - Expiration: None (persists indefinitely)
- Implementation: `src/location/GeoCache.zig`
3. **IP2Location Cache** 3. **IP2Location Cache**
- Purpose: Caches results from IP2Location.io API to minimize API calls
- Location: `$IP2LOCATION_CACHE_FILE` (default: `~/.cache/wttr/ip2location.cache`) - Location: `$IP2LOCATION_CACHE_FILE` (default: `~/.cache/wttr/ip2location.cache`)
- Format: Text file with header `#Ip2location:v2` followed by CSV lines: `ip,lat,lon,name` - Format: Binary (32-byte records)
Note that name stored at the end so any bytes are included, including commas
- Expiration: None (persists indefinitely) - Expiration: None (persists indefinitely)
- Storage: In-memory hash map (u128 IP → Location) + append-only file
- Implementation: `src/location/Ip2location.zig` (internal Cache struct)
4. **GeoIP Database** 4. **GeoIP Database**
- Location: `$WTTR_GEOLITE_PATH` (default: `~/.cache/wttr/GeoLite2-City.mmdb`) - Location: `$WTTR_GEOLITE_PATH` (default: `~/.cache/wttr/GeoLite2-City.mmdb`)
- Auto-downloaded if missing - Auto-downloaded if missing
- Implementation: `src/location/GeoLite2.zig`
### Location Resolver (`src/location/resolver.zig`) ### Location Resolver (`src/location/resolver.zig`)

48
src/cache/Cache.zig vendored
View file

@ -7,19 +7,17 @@ const log = std.log.scoped(.cache);
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
lru: Lru, lru: Lru,
/// Cache directory for L2 persistent cache cache_dir: []const u8,
cache_dir: ?[]const u8,
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,
}; };
pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache { pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache {
if (config.cache_dir) |d| std.fs.makeDirAbsolute(config.cache_dir) catch |err| {
std.fs.makeDirAbsolute(d) catch |err| { if (err != error.PathAlreadyExists) return err;
if (err != error.PathAlreadyExists) return err; };
};
const cache = try allocator.create(Cache); const cache = try allocator.create(Cache);
errdefer allocator.destroy(cache); errdefer allocator.destroy(cache);
@ -27,17 +25,15 @@ pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache {
cache.* = Cache{ cache.* = Cache{
.allocator = allocator, .allocator = allocator,
.lru = try Lru.init(allocator, config.max_entries), .lru = try Lru.init(allocator, config.max_entries),
.cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null, .cache_dir = try allocator.dupe(u8, config.cache_dir),
}; };
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
if (config.cache_dir) |d| { cache.loadFromDir() catch |err| {
cache.loadFromDir() catch |err| { std.log.warn("Failed to load cache from {s}: {}", .{ config.cache_dir, err });
std.log.warn("Failed to load cache from {s}: {}", .{ d, err }); };
};
}
return cache; return cache;
} }
@ -65,10 +61,8 @@ pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !
const now = std.time.milliTimestamp(); const now = std.time.milliTimestamp();
const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); const expires = now + @as(i64, @intCast(ttl_seconds * 1000));
// Write to L2 (disk) first if cache_dir is set // Write to L2 (disk) first
if (self.cache_dir != null) { try self.saveToFile(key, value, expires);
try self.saveToFile(key, value, expires);
}
// Add to L1 (memory) // Add to L1 (memory)
try self.lru.put(key, value, expires); try self.lru.put(key, value, expires);
@ -76,7 +70,7 @@ pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !
pub fn deinit(self: *Cache) void { pub fn deinit(self: *Cache) void {
self.lru.deinit(); self.lru.deinit();
if (self.cache_dir) |d| self.allocator.free(d); self.allocator.free(self.cache_dir);
self.allocator.destroy(self); self.allocator.destroy(self);
} }
@ -102,12 +96,10 @@ const CacheEntry = struct {
/// if the file access fails OR if the data has expired. /// if the file access fails OR if the data has expired.
/// If the data has expired, the file will be deleted /// 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;
const filename = try self.getCacheFilename(key); const filename = try self.getCacheFilename(key);
defer self.allocator.free(filename); defer self.allocator.free(filename);
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }); const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir, filename });
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
return self.loadFromFilePath(file_path); return self.loadFromFilePath(file_path);
@ -163,12 +155,10 @@ fn deserialize(allocator: std.mem.Allocator, reader: *std.Io.Reader) !CacheEntry
} }
fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !void { fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !void {
if (self.cache_dir == null) return error.NoCacheDir;
const filename = try self.getCacheFilename(key); const filename = try self.getCacheFilename(key);
defer self.allocator.free(filename); defer self.allocator.free(filename);
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }); const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir, filename });
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
const file = try std.fs.cwd().createFile(file_path, .{}); const file = try std.fs.cwd().createFile(file_path, .{});
@ -182,16 +172,14 @@ fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !v
} }
fn loadFromDir(self: *Cache) !void { fn loadFromDir(self: *Cache) !void {
if (self.cache_dir == null) return error.NoCacheDir; var dir = try std.fs.cwd().openDir(self.cache_dir, .{ .iterate = true });
var dir = try std.fs.cwd().openDir(self.cache_dir.?, .{ .iterate = true });
defer dir.close(); defer dir.close();
var it = dir.iterate(); var it = dir.iterate();
while (try it.next()) |entry| { while (try it.next()) |entry| {
if (entry.kind != .file) continue; if (entry.kind != .file) continue;
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name }); const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir, entry.name });
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
const cached = self.loadFromFilePath(file_path) catch continue; const cached = self.loadFromFilePath(file_path) catch continue;
@ -203,12 +191,10 @@ fn loadFromDir(self: *Cache) !void {
} }
fn deleteFile(self: *Cache, key: []const u8) void { fn deleteFile(self: *Cache, key: []const u8) void {
if (self.cache_dir == null) return;
const filename = self.getCacheFilename(key) catch @panic("OOM"); const filename = self.getCacheFilename(key) catch @panic("OOM");
defer self.allocator.free(filename); defer self.allocator.free(filename);
const file_path = std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }) catch @panic("OOM"); const file_path = std.fs.path.join(self.allocator, &.{ self.cache_dir, filename }) catch @panic("OOM");
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
std.fs.cwd().deleteFile(file_path) catch |e| { std.fs.cwd().deleteFile(file_path) catch |e| {

View file

@ -100,10 +100,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
} }
fn nowAt(coords: Coordinates) !i64 { fn nowAt(coords: Coordinates) !i64 {
const now = if (@import("builtin").is_test) const now = try zeit.instant(.{});
(try zeit.Time.fromISO8601("2026-01-09")).instant()
else
try zeit.instant(.{});
const offset = TimeZoneOffsets.getTimezoneOffset(coords); const offset = TimeZoneOffsets.getTimezoneOffset(coords);
const new = if (offset >= 0) const new = if (offset >= 0)
try now.add(.{ .minutes = @abs(offset) }) try now.add(.{ .minutes = @abs(offset) })

View file

@ -23,8 +23,8 @@ pub const VTable = struct {
pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData { pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordinates) !types.WeatherData {
// Generate cache key from coordinates // Generate cache key from coordinates
var buf: [256]u8 = undefined; const cache_key = try std.fmt.allocPrint(allocator, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude });
const cache_key = try std.fmt.bufPrint(&buf, "{d:.4},{d:.4}", .{ coords.latitude, coords.longitude }); defer allocator.free(cache_key);
// Check cache first // Check cache first
if (self.cache.get(cache_key)) |cached_raw| if (self.cache.get(cache_key)) |cached_raw|
@ -45,33 +45,3 @@ pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordi
pub fn deinit(self: WeatherProvider) void { pub fn deinit(self: WeatherProvider) void {
self.vtable.deinit(self.ptr); self.vtable.deinit(self.ptr);
} }
test "provider fetch" {
const Mock = @import("Mock.zig");
const MetNo = @import("MetNo.zig");
const cache = try Cache.init(std.testing.allocator, .{ .max_entries = 10, .cache_dir = null });
defer cache.deinit();
var mock = try Mock.init(std.testing.allocator);
defer mock.deinit();
mock.parse_fn = MetNo.parse;
const test_data = @embedFile("../tests/metno_test_data.json");
try mock.addResponse(.{ .latitude = 33.4484, .longitude = -112.0740 }, test_data);
const provider = mock.provider(cache);
const coords = Coordinates{ .latitude = 33.4484, .longitude = -112.0740 };
// First fetch - should allocate and cache
const weather1 = try provider.fetch(std.testing.allocator, coords);
defer weather1.deinit();
// Second fetch - should use cache
const weather2 = try provider.fetch(std.testing.allocator, coords);
defer weather2.deinit();
try std.testing.expect(weather1.forecast.len > 0);
try std.testing.expect(weather2.forecast.len > 0);
}