wttr/src/cache/Cache.zig
2026-01-09 13:54:11 -08:00

292 lines
9 KiB
Zig

const std = @import("std");
const Lru = @import("Lru.zig");
const Cache = @This();
const log = std.log.scoped(.cache);
allocator: std.mem.Allocator,
lru: Lru,
/// Cache directory for L2 persistent cache
cache_dir: ?[]const u8,
pub const Config = struct {
max_entries: usize = 1_000,
cache_dir: ?[]const u8,
};
pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache {
if (config.cache_dir) |d|
std.fs.makeDirAbsolute(d) catch |err| {
if (err != error.PathAlreadyExists) return err;
};
const cache = try allocator.create(Cache);
errdefer allocator.destroy(cache);
cache.* = Cache{
.allocator = allocator,
.lru = try Lru.init(allocator, config.max_entries),
.cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null,
};
cache.lru.setEvictionCallback(cache, evictCallback);
// Clean up expired files and populate L1 cache from L2
if (config.cache_dir) |d| {
cache.loadFromDir() catch |err| {
std.log.warn("Failed to load cache from {s}: {}", .{ d, err });
};
}
return cache;
}
fn evictCallback(ctx: *anyopaque, key: []const u8) void {
const self: *Cache = @ptrCast(@alignCast(ctx));
self.deleteFile(key);
}
pub fn get(self: *Cache, key: []const u8) ?[]const u8 {
// Check L1 (memory) first
if (self.lru.get(key)) |value| return value;
// Check L2 (disk)
const cached = self.loadFromFile(key) catch return null;
defer cached.deinit(self.allocator);
// L2 exists - promote to L1
self.lru.put(key, cached.value, cached.expires) catch return null;
return self.lru.get(key);
}
pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void {
const now = std.time.milliTimestamp();
const expires = now + @as(i64, @intCast(ttl_seconds * 1000));
// Write to L2 (disk) first if cache_dir is set
if (self.cache_dir != null) {
try self.saveToFile(key, value, expires);
}
// Add to L1 (memory)
try self.lru.put(key, value, expires);
}
pub fn deinit(self: *Cache) void {
self.lru.deinit();
if (self.cache_dir) |d| self.allocator.free(d);
self.allocator.destroy(self);
}
fn getCacheFilename(self: *Cache, key: []const u8) ![]const u8 {
var hasher = std.hash.Wyhash.init(0);
hasher.update(key);
const hash = hasher.final();
return std.fmt.allocPrint(self.allocator, "{x}.json", .{hash});
}
const CacheEntry = struct {
key: []const u8,
value: []const u8,
expires: i64,
pub fn deinit(self: CacheEntry, allocator: std.mem.Allocator) void {
allocator.free(self.key);
allocator.free(self.value);
}
};
/// Loads cached value from file (path calculated by the key). An error will be thrown
/// if the file access fails OR if the data has expired.
/// If the data has expired, the file will be deleted
fn loadFromFile(self: *Cache, key: []const u8) !CacheEntry {
if (self.cache_dir == null) return error.NoCacheDir;
const filename = try self.getCacheFilename(key);
defer self.allocator.free(filename);
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename });
defer self.allocator.free(file_path);
return self.loadFromFilePath(file_path);
}
/// Loads cached value from file (using a path). An error will be thrown
/// if the file access fails OR if the data has expired.
/// If the data has expired, the file will be deleted
fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
const file = try std.fs.cwd().openFile(file_path, .{});
defer file.close();
var buffer: [1 * 1024 * 1024]u8 = undefined;
var file_reader = file.reader(&buffer);
const reader = &file_reader.interface;
const cached = try deserialize(self.allocator, reader);
errdefer cached.deinit(self.allocator);
// Check if expired
const now = std.time.milliTimestamp();
if (cached.expires <= now) {
// We're expired, delete expired file
self.deleteFile(cached.key);
return error.Expired;
}
return cached;
}
fn serialize(writer: *std.Io.Writer, key: []const u8, value: []const u8, expires: i64) !void {
const entry = CacheEntry{ .key = key, .value = value, .expires = expires };
try writer.print("{f}", .{std.json.fmt(entry, .{})});
}
fn deserialize(allocator: std.mem.Allocator, reader: *std.Io.Reader) !CacheEntry {
var json_reader = std.json.Reader.init(allocator, reader);
defer json_reader.deinit();
const parsed = try std.json.parseFromTokenSource(
CacheEntry,
allocator,
&json_reader,
.{},
);
defer parsed.deinit();
return .{
.key = try allocator.dupe(u8, parsed.value.key),
.value = try allocator.dupe(u8, parsed.value.value),
.expires = parsed.value.expires,
};
}
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);
defer self.allocator.free(filename);
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename });
defer self.allocator.free(file_path);
const file = try std.fs.cwd().createFile(file_path, .{});
defer file.close();
var buffer: [4096]u8 = undefined;
var file_writer = file.writer(&buffer);
const writer = &file_writer.interface;
try serialize(writer, key, value, expires);
try writer.flush();
}
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 });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
if (entry.kind != .file) continue;
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name });
defer self.allocator.free(file_path);
const cached = self.loadFromFilePath(file_path) catch continue;
defer cached.deinit(self.allocator);
// Populate L1 cache from L2
self.lru.put(cached.key, cached.value, cached.expires) catch continue;
}
}
fn deleteFile(self: *Cache, key: []const u8) void {
if (self.cache_dir == null) return;
const filename = self.getCacheFilename(key) catch @panic("OOM");
defer self.allocator.free(filename);
const file_path = std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }) catch @panic("OOM");
defer self.allocator.free(file_path);
std.fs.cwd().deleteFile(file_path) catch |e| {
log.warn("Error deleting expired cache file {s}: {}", .{ file_path, e });
};
}
test "serialize and deserialize" {
const allocator = std.testing.allocator;
const key = "test_key";
const value = "test_value";
const expires: i64 = 1234567890;
var buffer: [1024]u8 = undefined;
var fixed_writer = std.Io.Writer.fixed(&buffer);
try serialize(&fixed_writer, key, value, expires);
try fixed_writer.flush();
const serialized = buffer[0..fixed_writer.end];
try std.testing.expectEqualStrings("{\"key\":\"test_key\",\"value\":\"test_value\",\"expires\":1234567890}", serialized);
var fixed_reader = std.Io.Reader.fixed(serialized);
const cached = try deserialize(allocator, &fixed_reader);
defer cached.deinit(allocator);
try std.testing.expectEqualStrings(key, cached.key);
try std.testing.expectEqualStrings(value, cached.value);
try std.testing.expectEqual(expires, cached.expires);
}
test "deserialize handles integer expires" {
const allocator = std.testing.allocator;
const json = "{\"key\":\"k\",\"value\":\"v\",\"expires\":9999999999999}";
var fixed_reader = std.Io.Reader.fixed(json);
const cached = try deserialize(allocator, &fixed_reader);
defer cached.deinit(allocator);
try std.testing.expectEqualStrings("k", cached.key);
try std.testing.expectEqualStrings("v", cached.value);
try std.testing.expectEqual(9999999999999, cached.expires);
}
test "L1/L2 cache flow" {
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 = try tmp_dir.dir.realpath(".", &path_buf);
const cache = try Cache.init(allocator, .{ .max_entries = 10, .cache_dir = cache_dir });
defer cache.deinit();
// Put item in cache
try cache.put("key1", "value1", 900);
// Should be in L1
try std.testing.expectEqualStrings("value1", cache.get("key1").?);
// Manually remove from L1 to simulate eviction
if (cache.lru.map.fetchRemove("key1")) |kv| {
allocator.free(kv.value.value);
allocator.free(kv.key);
}
// key1 should not be in L1
try std.testing.expect(cache.lru.get("key1") == null);
// Get should promote from L2 to L1
try std.testing.expectEqualStrings("value1", cache.get("key1").?);
// Now it should be in L1
try std.testing.expectEqualStrings("value1", cache.lru.get("key1").?);
}