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").?); }