cache: a byte budget of 256 bytes per entry bounds the cached answers, evicting by clock, and a full or failed copy is counted instead of swallowed

This commit is contained in:
2026-10-22 08:54:59 +02:00
parent d951d03b7d
commit e4e405f8e9
11 changed files with 344 additions and 96 deletions
+39 -17
View File
@@ -937,14 +937,14 @@ const Context = struct {
/// A response the cache declines to hold, or an allocation it cannot make,
/// is not a query failure: the answer already went out. `DnsCache.stats`
/// carries what happened.
/// counts both.
fn cachePut(ctx: *Context, key: []const u8, message: []const u8) void {
const class = dns_cache.classify(message, ctx.policy.negative_ttl_max) orelse return;
ctx.handler.cache_mutex.lockUncancelable(ctx.io);
defer ctx.handler.cache_mutex.unlock(ctx.io);
const cache = ctx.handler.cache orelse return;
cache.put(ctx.now_s, key, message, class) catch {};
cache.put(ctx.now_s, key, message, class);
}
/// The bytes to send upstream. Null means the rewrite did not fit and the
@@ -2653,7 +2653,7 @@ test "a forward zone answers from the cache and never reaches the pool" {
});
defer zones.deinit(testing.allocator);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
// The name is blocklisted, so only the zone path can answer it from the
@@ -2692,7 +2692,7 @@ test "a forward zone answers from the cache and never reaches the pool" {
false,
null,
);
try cache.put(std.Io.Clock.real.now(t.io()).toSeconds(), key, zone_reply, .{ .ttl_seconds = 300, .negative = false });
cache.put(std.Io.Clock.real.now(t.io()).toSeconds(), key, zone_reply, .{ .ttl_seconds = 300, .negative = false });
var buf: [udp_limit_min]u8 = undefined;
const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf)));
@@ -2817,11 +2817,33 @@ test "a handler with no snapshot answers unfiltered and counts it" {
// --- cache -----------------------------------------------------------------
test "a copy the cache cannot allocate is counted and the answer still goes out" {
var t: TestIo = .init();
defer t.deinit();
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{});
var cache: dns_cache.DnsCache = try .init(failing.allocator(), .sized(8));
defer cache.deinit();
failing.fail_index = failing.alloc_index;
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bare(h_owner.client(fake.client()));
h.cache = &cache;
h.policy.negative_ttl_max = 3600;
var buf: [udp_limit_min]u8 = undefined;
const p = try packet.parse(try expectReply(udp(&h, t.io(), query_bytes, &buf)));
try testing.expectEqual(@as(u16, 1), p.header.ancount);
try testing.expectEqual(@as(u32, 0), cache.len());
try testing.expectEqual(@as(u64, 1), cache.stats.copy_failures);
}
test "a miss stores the answer and the next query is a hit with a fresh id" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -2850,8 +2872,8 @@ test "a cache and a rate-limiter swap land in the middle of a query without a da
defer t.deinit();
const io = t.io();
var retired_cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var live_cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var retired_cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
var live_cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer live_cache.deinit();
var retired_limiter: rate_limiter.RateLimiter = try .init(
@@ -2914,7 +2936,7 @@ test "a blocked answer is never cached" {
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -3126,7 +3148,7 @@ test "ecs forward keeps the subnet in the query and splits the cache key" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -3202,7 +3224,7 @@ test "a non-IN class query skips filtering and the cache" {
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -3335,7 +3357,7 @@ test "the upstream pipeline explains every allowed and blocked answer" {
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var queue_buf: [8]logger_mod.Entry = undefined;
@@ -3545,7 +3567,7 @@ test "a local record and a forward zone are explained with no upstream named" {
});
defer zones.deinit(testing.allocator);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var snapshot = try provenanceSnapshot(&.{});
@@ -3586,7 +3608,7 @@ test "a local record and a forward zone are explained with no upstream named" {
false,
null,
);
try cache.put(
cache.put(
std.Io.Clock.real.now(io).toSeconds(),
key,
b.finish(),
@@ -3678,7 +3700,7 @@ test "a forward zone that answers names its resolver, and its cache hit does not
});
defer zones.deinit(testing.allocator);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var snapshot = try provenanceSnapshot(&.{});
@@ -4434,7 +4456,7 @@ test "a failed ecs strip keeps the query out of the cache in both directions" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
// An entry stored under the subnet-free key every other client shares.
@@ -4457,7 +4479,7 @@ test "a failed ecs strip keeps the query out of the cache in both directions" {
false,
null,
);
try cache.put(
cache.put(
std.Io.Clock.real.now(t.io()).toSeconds(),
global_key,
planted,
@@ -4494,7 +4516,7 @@ test "forward mode refuses to cache a query carrying two subnets" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };