//! 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 Lru = @This(); allocator: std.mem.Allocator, /// Zig 0.16 reads the clock through `Io` rather than `std.time`. io: std.Io, map: std.StringHashMap(Entry), 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_ctx: ?*anyopaque = null, const Entry = struct { value: []const u8, expires: i64, access_count: u64, }; pub fn init(allocator: std.mem.Allocator, io: std.Io, max_entries: usize) !Lru { return .{ .allocator = allocator, .io = io, .map = std.StringHashMap(Entry).init(allocator), .max_entries = max_entries, }; } pub fn setEvictionCallback(self: *Lru, ctx: *anyopaque, callback: *const fn (ctx: *anyopaque, key: []const u8) void) void { self.evict_ctx = ctx; self.evict_fn = callback; } 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; if (now_ms > entry.expires + self.retention_ms) { self.remove(key); return null; } entry.access_count += 1; return .{ .key = key, .value = entry.value, .expires = entry.expires }; } 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 // already have persisted the new value under the same key. if (self.map.fetchRemove(key)) |old| { self.allocator.free(old.value.value); self.allocator.free(old.key); } if (self.map.count() >= self.max_entries) { self.evictOldest(); } const key_copy = try self.allocator.dupe(u8, key); errdefer self.allocator.free(key_copy); const value_copy = try self.allocator.dupe(u8, value); errdefer self.allocator.free(value_copy); try self.map.put(key_copy, .{ .value = value_copy, .expires = expires, .access_count = 0, }); } fn evictOldest(self: *Lru) void { var oldest_key: ?[]const u8 = null; var oldest_access: u64 = std.math.maxInt(u64); var it = self.map.iterator(); while (it.next()) |entry| { if (entry.value_ptr.access_count < oldest_access) { oldest_access = entry.value_ptr.access_count; oldest_key = entry.key_ptr.*; } } if (oldest_key) |key| { self.remove(key); } } fn remove(self: *Lru, key: []const u8) void { if (self.map.fetchRemove(key)) |kv| { if (self.evict_fn) |callback| { callback(self.evict_ctx.?, kv.key); } self.allocator.free(kv.value.value); self.allocator.free(kv.key); } } pub fn deinit(self: *Lru) void { var it = self.map.iterator(); while (it.next()) |entry| { self.allocator.free(entry.value_ptr.value); self.allocator.free(entry.key_ptr.*); } self.map.deinit(); } pub const Iterator = struct { inner: std.StringHashMap(Entry).Iterator, pub const Item = struct { key: []const u8, value: []const u8, expires: i64, }; pub fn next(self: *Iterator) ?Item { const entry = self.inner.next() orelse return null; return Item{ .key = entry.key_ptr.*, .value = entry.value_ptr.value, .expires = entry.value_ptr.expires, }; } }; pub fn iterator(self: *Lru) Iterator { return .{ .inner = self.map.iterator() }; } test "LRU basic operations" { var lru = try Lru.init(std.testing.allocator, std.testing.io, 3); defer lru.deinit(); try lru.put("key1", "value1", 9999999999999); try std.testing.expectEqualStrings("value1", lru.get("key1").?); } test "LRU eviction" { var lru = try Lru.init(std.testing.allocator, std.testing.io, 2); defer lru.deinit(); try lru.put("key1", "value1", 9999999999999); try lru.put("key2", "value2", 9999999999999); try lru.put("key3", "value3", 9999999999999); try std.testing.expect(lru.get("key1") == null); } test "LRU expired entry returns null" { var lru = try Lru.init(std.testing.allocator, std.testing.io, 10); defer lru.deinit(); // Put item with past expiration time const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds(); const past_expires = now - 1000; try lru.put("key1", "value1", past_expires); // Get should return null for expired entry const result = lru.get("key1"); 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()); }