diff --git a/src/network/Network.zig b/src/network/Network.zig index ceaf93f0d..a48205d0b 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -233,7 +233,11 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { const cache = if (config.httpCacheDir()) |cache_dir_path| Cache{ .kind = .{ - .sqlite = SqliteCache.init(allocator, .{ .path = cache_dir_path }) catch |e| { + .sqlite = SqliteCache.init( + allocator, + .{ .path = cache_dir_path }, + config.httpCacheEntryLimit(), + ) catch |e| { log.err(.cache, "failed to init", .{ .kind = "SqliteCache", .path = cache_dir_path, diff --git a/src/network/cache/SqliteCache.zig b/src/network/cache/SqliteCache.zig index 88541f127..763dd2122 100644 --- a/src/network/cache/SqliteCache.zig +++ b/src/network/cache/SqliteCache.zig @@ -41,6 +41,7 @@ pub const SqliteCache = @This(); allocator: std.mem.Allocator, pool: Pool, +entry_limit: u32, const cache_migrations: []const Migration = &.{ .{ .sql = @@ -67,6 +68,7 @@ const cache_migrations: []const Migration = &.{ \\ ) strict }, .{ .sql = "create index header_url on header(url)" }, + .{ .sql = "create index cache_stored_at on cache(stored_at)" }, }; pub const SqliteCachePath = union(enum) { @@ -85,7 +87,7 @@ pub const SqliteCachePath = union(enum) { } }; -pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath) !SqliteCache { +pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath, entry_limit: u32) !SqliteCache { var pool = switch (path) { .memory => try Pool.init(allocator, ":memory:"), .path => |cache_dir| blk: { @@ -125,13 +127,25 @@ pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath) !SqliteCache { } log.info(.cache, "sqlite cache initialized", .{ .path = path, .version = version }); - return .{ .allocator = allocator, .pool = pool }; + return .{ .allocator = allocator, .pool = pool, .entry_limit = entry_limit }; } pub fn deinit(self: *SqliteCache) void { self.pool.deinit(self.allocator); } +fn evictOverflow(self: *SqliteCache, conn: Conn) !void { + const limit = self.entry_limit; + if (limit == 0) return; + + try conn.exec( + \\ delete from cache + \\ where url not in ( + \\ select url from cache order by stored_at desc limit $1 + \\ ) + , .{@as(i64, @intCast(limit))}); +} + pub fn get(self: *SqliteCache, arena: std.mem.Allocator, req: CacheGetRequest) !CacheGetResult { const conn = try self.pool.acquire(); defer self.pool.release(conn); @@ -270,6 +284,7 @@ pub fn put(self: *SqliteCache, req: CachePutRequest, body: []const u8) !void { ); } + try self.evictOverflow(conn); try conn.commit(); log.debug(.cache, "put", .{ .url = req.url, .body_len = body.len }); @@ -376,7 +391,7 @@ pub fn renew(self: *SqliteCache, _: std.mem.Allocator, req: RenewResponse) !void const testing = std.testing; fn setupCache(allocator: std.mem.Allocator) !Cache { - return Cache{ .kind = .{ .sqlite = try .init(allocator, .memory) } }; + return Cache{ .kind = .{ .sqlite = try .init(allocator, .memory, 0) } }; } test "SqliteCache: Migrations" { @@ -900,3 +915,50 @@ test "SqliteCache: renew preserves body" { try testing.expect(result == .hit); try testing.expectEqualStrings("original body", result.hit.data.buffer); } + +test "SqliteCache: evicts oldest entries over the limit" { + var cache = Cache{ .kind = .{ .sqlite = try .init(testing.allocator, .memory, 3) } }; + defer cache.deinit(); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const now: u64 = @intCast(std.Io.Timestamp.now(testing.io, .boot).toSeconds()); + + const urls = [_][:0]const u8{ + "https://example.com/a", + "https://example.com/b", + "https://example.com/c", + "https://example.com/d", + }; + + for (urls, 0..) |url, i| { + try cache.put(.{ + .url = url, + .content_type = "text/html", + .status = 200, + .stored_at = now + i, + .age_at_store = 0, + .cache_control = .{ .max_age = 600 }, + .headers = &.{}, + .vary_headers = &.{}, + }, url); + } + + const evicted = try cache.get(arena.allocator(), .{ + .url = "https://example.com/a", + .timestamp = now, + .request_headers = &.{}, + }); + try testing.expect(evicted == .miss); + + for (urls[1..]) |url| { + const hit = try cache.get(arena.allocator(), .{ + .url = url, + .timestamp = now + urls.len, + .request_headers = &.{}, + }); + try testing.expect(hit == .hit); + try testing.expectEqualStrings(url, hit.hit.data.buffer); + } +}