216 lines
6.8 KiB
Zig
216 lines
6.8 KiB
Zig
//! 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());
|
|
}
|