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
+3
View File
@@ -8,6 +8,9 @@ Sections are written by hand. Nothing here is generated from commit messages: th
### Fixed ### Fixed
- **The DNS cache's memory is bounded in bytes, not only in entries.** Each cached answer is copied at whatever size the upstream sent, so 10000 entries could in principle hold 10000 large answers. The answers now share a budget of 256 bytes per `cache.size` entry, about 2.4 MiB at the default, and when a new answer does not fit, answers nobody has asked for lately make room for it. An answer larger than the whole budget is not cached. `/metrics` reports `nxdns_cache_response_bytes` against `nxdns_cache_max_bytes`, and counts `nxdns_cache_oversized_total` and `nxdns_cache_copy_failures_total`.
- **The DNS cache no longer evicts an answer while it has free room.** A new answer replaced an older one whenever the eviction scan met it first, even with most of the cache empty. A copy of an answer that failed for lack of memory was also dropped without a trace; it is now counted, and a failed replacement keeps the answer already cached.
- **A refused query history says what to do.** When nxdns refuses to open `querylog.db`, because a newer release wrote it or it cannot be converted, the message now gives the remedy: run the newer release again, put back the `querylog.db.pre-migrate-*` copy, or move the file and its `-wal` and `-shm` files aside to start an empty history. It used to point at a documentation page. - **A refused query history says what to do.** When nxdns refuses to open `querylog.db`, because a newer release wrote it or it cannot be converted, the message now gives the remedy: run the newer release again, put back the `querylog.db.pre-migrate-*` copy, or move the file and its `-wal` and `-shm` files aside to start an empty history. It used to point at a documentation page.
- **A refused start no longer gives advice that does not apply.** After a query history refusal, `nxdns run` also printed "run `nxdns check`" and "load one with `nxdns import <file>`", though the configuration was fine. The import hint now appears only when the database has no usable upstream, and neither hint follows a database refusal. - **A refused start no longer gives advice that does not apply.** After a query history refusal, `nxdns run` also printed "run `nxdns check`" and "load one with `nxdns import <file>`", though the configuration was fine. The import hint now appears only when the database has no usable upstream, and neither hint follows a database refusal.
+1 -1
View File
@@ -64,7 +64,7 @@ What a blocked query gets back.
| Key | Type | Default | Allowed values | What it does | | Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---| |---|---|---|---|---|
| `cache.size` | integer | 10000 | 1–1000000 entries | How many answers nxdns caches. The memory is reserved at startup. | | `cache.size` | integer | 10000 | 1–1000000 entries | How many answers nxdns caches. The memory for the cached answers grows with it, and when that memory is full, answers nobody has asked for lately make room for new ones. |
| `cache.negative_ttl_max` | integer | 3600 | 0–86400 s | The longest nxdns caches an answer that a name does not exist; 0 turns that off. | | `cache.negative_ttl_max` | integer | 3600 | 0–86400 s | The longest nxdns caches an answer that a name does not exist; 0 turns that off. |
The cache cannot be turned off; the smallest is `cache.size = 1`. The cache cannot be turned off; the smallest is `cache.size = 1`.
+2 -2
View File
@@ -75,11 +75,11 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
### 6. DNS cache bounded in bytes ### 6. DNS cache bounded in bytes
- `DnsCache` keeps its entry count and adds a budget on live response bytes, tracked separately from `memoryBytes` (which also counts slots and index). `put` evicts by CLOCK until the copy fits. A response larger than the whole budget is not cached and is counted. A replacement keeps the old entry if allocating the new copy fails. A failed copy allocation is counted; today `handler.zig:918` discards it with `catch {}` and `DnsCache.Stats` has no field for it. The answer already obtained is still sent. The budget, the live bytes and both counters appear in cache diagnostics. - `DnsCache` keeps its entry count and adds a budget on live response bytes, tracked separately from `memoryBytes` (which also counts slots and index). `put` evicts by CLOCK until the copy fits. A response larger than the whole budget is not cached and is counted. A replacement keeps the old entry if allocating the new copy fails. A failed copy allocation is counted; today `handler.zig:918` discards it with `catch {}` and `DnsCache.Stats` has no field for it. The answer already obtained is still sent. The budget, the live bytes and both counters appear in cache diagnostics.
- New config key `cache.max_bytes`, through the config field checklist. Default from the Pi's measured live response bytes. - The budget is not a config key (owner, 2026-10-21: an operator has no basis for choosing it). It follows `cache.size`: 256 bytes per entry (about 2.4 MiB at the default 10000), about 2.5 times the Pi's measured average of 100 bytes per cached response, and never less than one 65535-byte message, so a small cache still caches any answer a large one would. The live bytes and the budget are reported as `nxdns_cache_response_bytes` and `nxdns_cache_max_bytes`.
## Propagation ## Propagation
- Config: `cache.max_bytes`; the changed default of `logging.query_log_buffer_max`. Config field checklist for the new key: model key + round-trip drift guards, validation + reference, settings API view, openapi.yaml, admin types + settings control, contract samples regenerated, docs config reference, CHANGELOG. - Config: the changed default of `logging.query_log_buffer_max`. No new key (item 6).
- Diagnostics: the item-0 counters, `ConcurrencyUnavailable`, oversized cache responses, cache live bytes and budget. - Diagnostics: the item-0 counters, `ConcurrencyUnavailable`, oversized cache responses, cache live bytes and budget.
- Docs: the user docs make no memory claim. The measured RSS, refresh peak and thread count at the Pi's blocklist size go into the measurement record below and the release's CHANGELOG entry. - Docs: the user docs make no memory claim. The measured RSS, refresh peak and thread count at the Pi's blocklist size go into the measurement record below and the release's CHANGELOG entry.
+1 -1
View File
@@ -718,7 +718,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const both = blk: { const both = blk: {
const c = try gpa.create(dns_cache.DnsCache); const c = try gpa.create(dns_cache.DnsCache);
errdefer gpa.destroy(c); errdefer gpa.destroy(c);
c.* = try .init(gpa, cfg.cache); c.* = try .init(gpa, .sized(cfg.cache.size));
errdefer c.deinit(); errdefer c.deinit();
const l = try gpa.create(rate_limiter.RateLimiter); const l = try gpa.create(rate_limiter.RateLimiter);
+263 -66
View File
@@ -21,7 +21,6 @@ const std = @import("std");
const packet = @import("../dns/packet.zig"); const packet = @import("../dns/packet.zig");
const record = @import("../dns/record.zig"); const record = @import("../dns/record.zig");
const types = @import("../dns/types.zig"); const types = @import("../dns/types.zig");
const model = @import("../config/model.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@@ -244,9 +243,27 @@ fn soaTtls(p: packet.Packet) ?SoaTtls {
return null; return null;
} }
/// Field-for-field the cache section of the config model. Aliasing it keeps one pub const Config = struct {
/// type for one concept: `size` counts entries, not bytes (ruling 7). /// Entries, not bytes (ruling 7): `cache.size`.
pub const Config = model.Cache; size: u32,
/// The budget on stored response bytes.
max_bytes: usize,
/// The response bytes each entry is budgeted. The Pi's cached answers
/// averaged about 100 bytes; most answers fit a classic 512-byte datagram,
/// and the allowance is an average across entries, not a per-entry cap.
pub const bytes_per_entry = 256;
/// The budget never refuses an answer the largest DNS message could carry,
/// so a small `cache.size` still caches what a large one would.
pub const min_bytes = std.math.maxInt(u16);
/// The configuration for `size` entries: the byte budget follows the entry
/// count rather than being a setting of its own.
pub fn sized(size: u32) Config {
return .{ .size = size, .max_bytes = @max(@as(usize, size) * bytes_per_entry, min_bytes) };
}
};
pub const Stats = struct { pub const Stats = struct {
hits: u64 = 0, hits: u64 = 0,
@@ -257,6 +274,11 @@ pub const Stats = struct {
/// Entries dropped because aging their bytes made them unparsable. A /// Entries dropped because aging their bytes made them unparsable. A
/// poisoned entry never serves twice. /// poisoned entry never serves twice.
invalid_hits: u64 = 0, invalid_hits: u64 = 0,
/// Responses larger than the whole byte budget. Answered, never cached.
oversized: u64 = 0,
/// Copies the allocator refused. The answer was still sent, and a key
/// already cached kept its previous entry.
copy_failures: u64 = 0,
}; };
/// The key lives inline so that a slot owns no memory besides its response /// The key lives inline so that a slot owns no memory besides its response
@@ -271,12 +293,16 @@ const Slot = struct {
negative: bool, negative: bool,
referenced: bool, referenced: bool,
occupied: bool, occupied: bool,
/// The next free slot while this one is free; `no_slot` ends the list.
next_free: u32,
fn keySlice(self: *const Slot) []const u8 { fn keySlice(self: *const Slot) []const u8 {
return self.key[0..self.key_len]; return self.key[0..self.key_len];
} }
}; };
const no_slot = std.math.maxInt(u32);
/// Key bytes to slot number. The stored key slices point into the slots array, /// Key bytes to slot number. The stored key slices point into the slots array,
/// which is allocated once and never moves. /// which is allocated once and never moves.
const Index = std.StringHashMapUnmanaged(u32); const Index = std.StringHashMapUnmanaged(u32);
@@ -288,6 +314,12 @@ pub const DnsCache = struct {
/// The CLOCK hand: the next slot the replacement scan will examine. /// The CLOCK hand: the next slot the replacement scan will examine.
hand: u32, hand: u32,
occupied: u32, occupied: u32,
/// Free slots, so an insert never evicts while one is unused.
free_head: u32,
/// The budget on `live_bytes`, which counts the stored responses alone:
/// the slots and the index are sized by `size` and reserved at `init`.
max_bytes: usize,
live_bytes: usize,
stats: Stats, stats: Stats,
/// Allocates the slots and the index up front. `ensureTotalCapacity` at the /// Allocates the slots and the index up front. `ensureTotalCapacity` at the
@@ -296,7 +328,7 @@ pub const DnsCache = struct {
pub fn init(gpa: Allocator, config: Config) Allocator.Error!DnsCache { pub fn init(gpa: Allocator, config: Config) Allocator.Error!DnsCache {
const slots = try gpa.alloc(Slot, config.size); const slots = try gpa.alloc(Slot, config.size);
errdefer gpa.free(slots); errdefer gpa.free(slots);
for (slots) |*slot| { for (slots, 0..) |*slot, i| {
slot.* = .{ slot.* = .{
.key = undefined, .key = undefined,
.key_len = 0, .key_len = 0,
@@ -306,6 +338,7 @@ pub const DnsCache = struct {
.negative = false, .negative = false,
.referenced = false, .referenced = false,
.occupied = false, .occupied = false,
.next_free = if (i + 1 < slots.len) @intCast(i + 1) else no_slot,
}; };
} }
@@ -319,6 +352,9 @@ pub const DnsCache = struct {
.index = index, .index = index,
.hand = 0, .hand = 0,
.occupied = 0, .occupied = 0,
.free_head = if (slots.len == 0) no_slot else 0,
.max_bytes = config.max_bytes,
.live_bytes = 0,
.stats = .{}, .stats = .{},
}; };
} }
@@ -334,42 +370,59 @@ pub const DnsCache = struct {
/// Stores a clone of `response` under `key`. A key already present is /// Stores a clone of `response` under `key`. A key already present is
/// replaced in place, which keeps the entry count and the CLOCK order /// replaced in place, which keeps the entry count and the CLOCK order
/// unchanged. /// unchanged. Entries are evicted by CLOCK until the clone fits the byte
/// budget.
/// ///
/// The clone never advertises a TTL that outlives the entry: a negative /// The clone never advertises a TTL that outlives the entry: a negative
/// response has its SOA TTL lowered to the entry's lifetime, and a positive /// response has its SOA TTL lowered to the entry's lifetime, and a positive
/// one has every record above `max_ttl_seconds` lowered to that ceiling. The /// one has every record above `max_ttl_seconds` lowered to that ceiling. The
/// caller's buffer is not touched. /// caller's buffer is not touched.
/// ///
/// A cache configured with zero entries stores nothing. That is caching /// Nothing here fails the query that produced `response`, which has already
/// switched off, not a failure, so it is not an error. /// been answered. A response over the whole budget and a clone the
/// allocator refuses are each counted in `stats` and leave the cache as it
/// was. A cache configured with zero entries stores nothing: caching
/// switched off, not a failure.
pub fn put( pub fn put(
self: *DnsCache, self: *DnsCache,
now_s: i64, now_s: i64,
key: []const u8, key: []const u8,
response: []const u8, response: []const u8,
class: Class, class: Class,
) Allocator.Error!void { ) void {
std.debug.assert(key.len <= max_key_len); std.debug.assert(key.len <= max_key_len);
if (self.slots.len == 0) return; if (self.slots.len == 0) return;
if (response.len > self.max_bytes) {
self.stats.oversized += 1;
return;
}
const bytes = try self.gpa.dupe(u8, response); // Cloned before anything is released, so a refused allocation leaves a
errdefer self.gpa.free(bytes); // replaced key's previous entry serving.
const bytes = self.gpa.dupe(u8, response) catch {
self.stats.copy_failures += 1;
return;
};
if (class.negative) clampSoaTtl(bytes, class.ttl_seconds) else capRecordTtls(bytes); if (class.negative) clampSoaTtl(bytes, class.ttl_seconds) else capRecordTtls(bytes);
const expires_at_s = now_s +| @as(i64, class.ttl_seconds); const expires_at_s = now_s +| @as(i64, class.ttl_seconds);
if (self.index.get(key)) |existing| { if (self.index.get(key)) |existing| {
const slot = &self.slots[existing]; const slot = &self.slots[existing];
self.live_bytes -= slot.bytes.len;
self.gpa.free(slot.bytes); self.gpa.free(slot.bytes);
slot.bytes = &.{};
self.reclaimBytes(now_s, bytes.len, existing);
slot.bytes = bytes; slot.bytes = bytes;
slot.stored_at_s = now_s; slot.stored_at_s = now_s;
slot.expires_at_s = expires_at_s; slot.expires_at_s = expires_at_s;
slot.negative = class.negative; slot.negative = class.negative;
self.live_bytes += bytes.len;
self.stats.inserts += 1; self.stats.inserts += 1;
return; return;
} }
self.reclaimBytes(now_s, bytes.len, null);
const index = self.acquireSlot(now_s); const index = self.acquireSlot(now_s);
const slot = &self.slots[index]; const slot = &self.slots[index];
@memcpy(slot.key[0..key.len], key); @memcpy(slot.key[0..key.len], key);
@@ -387,6 +440,7 @@ pub const DnsCache = struct {
gop.value_ptr.* = index; gop.value_ptr.* = index;
self.occupied += 1; self.occupied += 1;
self.live_bytes += bytes.len;
self.stats.inserts += 1; self.stats.inserts += 1;
} }
@@ -461,24 +515,36 @@ pub const DnsCache = struct {
/// The index term follows its layout of a key array, a value array and one /// The index term follows its layout of a key array, a value array and one
/// metadata byte per bucket. /// metadata byte per bucket.
pub fn memoryBytes(self: *const DnsCache) usize { pub fn memoryBytes(self: *const DnsCache) usize {
var total = self.slots.len * @sizeOf(Slot); return self.slots.len * @sizeOf(Slot) + self.live_bytes +
for (self.slots) |*slot| { @as(usize, self.index.capacity()) * (@sizeOf([]const u8) + @sizeOf(u32) + 1);
if (slot.occupied) total += slot.bytes.len;
}
total += @as(usize, self.index.capacity()) *
(@sizeOf([]const u8) + @sizeOf(u32) + 1);
return total;
} }
/// Returns a slot ready for a new entry, freeing whatever held it. /// A free slot for a new entry, evicting one by CLOCK when none is free.
fn acquireSlot(self: *DnsCache, now_s: i64) u32 {
if (self.free_head == no_slot) self.evictOne(now_s, null);
const index = self.free_head;
self.free_head = self.slots[index].next_free;
return index;
}
/// Evicts by CLOCK until `needed` more bytes fit the budget. `keep` is the
/// slot being replaced, whose bytes are already out of `live_bytes`.
fn reclaimBytes(self: *DnsCache, now_s: i64, needed: usize, keep: ?u32) void {
std.debug.assert(needed <= self.max_bytes);
while (self.live_bytes + needed > self.max_bytes) self.evictOne(now_s, keep);
}
/// Releases one occupied slot other than `keep`, chosen by CLOCK.
/// ///
/// An expired slot met on the way is reclaimed as an expiration rather than /// An expired slot met on the way is reclaimed as an expiration rather than
/// an eviction: it was already dead, and counting it as an eviction would /// an eviction: it was already dead, and counting it as an eviction would
/// report cache pressure that is not there. /// report cache pressure that is not there.
fn acquireSlot(self: *DnsCache, now_s: i64) u32 { fn evictOne(self: *DnsCache, now_s: i64, keep: ?u32) void {
const capacity: u32 = @intCast(self.slots.len); const capacity: u32 = @intCast(self.slots.len);
// Every occupied slot is examined at most twice: the first pass clears // Every candidate is examined at most twice: the first pass clears the
// the reference bits, the second finds a slot with none set. // reference bits, the second finds a slot with none set. A candidate
// exists, because both callers only get here while an entry other than
// `keep` holds bytes or a slot.
const limit = @as(usize, capacity) * 2; const limit = @as(usize, capacity) * 2;
var steps: usize = 0; var steps: usize = 0;
@@ -487,11 +553,11 @@ pub const DnsCache = struct {
self.hand = (self.hand + 1) % capacity; self.hand = (self.hand + 1) % capacity;
const slot = &self.slots[index]; const slot = &self.slots[index];
if (!slot.occupied) return index; if (!slot.occupied or index == keep) continue;
if (now_s >= slot.expires_at_s) { if (now_s >= slot.expires_at_s) {
self.release(index); self.release(index);
self.stats.expirations += 1; self.stats.expirations += 1;
return index; return;
} }
if (slot.referenced) { if (slot.referenced) {
slot.referenced = false; slot.referenced = false;
@@ -499,7 +565,7 @@ pub const DnsCache = struct {
} }
self.release(index); self.release(index);
self.stats.evictions += 1; self.stats.evictions += 1;
return index; return;
} }
unreachable; unreachable;
} }
@@ -509,11 +575,14 @@ pub const DnsCache = struct {
std.debug.assert(slot.occupied); std.debug.assert(slot.occupied);
const removed = self.index.remove(slot.keySlice()); const removed = self.index.remove(slot.keySlice());
std.debug.assert(removed); std.debug.assert(removed);
self.live_bytes -= slot.bytes.len;
self.gpa.free(slot.bytes); self.gpa.free(slot.bytes);
slot.bytes = &.{}; slot.bytes = &.{};
slot.key_len = 0; slot.key_len = 0;
slot.occupied = false; slot.occupied = false;
slot.referenced = false; slot.referenced = false;
slot.next_free = self.free_head;
self.free_head = index;
self.occupied -= 1; self.occupied -= 1;
} }
}; };
@@ -798,7 +867,7 @@ test "classify refuses every other rcode" {
} }
test "put and get round-trip a response with aged ttls" { test "put and get round-trip a response with aged ttls" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(8));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
@@ -808,7 +877,7 @@ test "put and get round-trip a response with aged ttls" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, class); cache.put(1000, key, response, class);
try testing.expectEqual(@as(u32, 1), cache.len()); try testing.expectEqual(@as(u32, 1), cache.len());
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
@@ -829,7 +898,7 @@ test "put and get round-trip a response with aged ttls" {
} }
test "a cached positive response caps its stored ttls at the sanity ceiling" { test "a cached positive response caps its stored ttls at the sanity ceiling" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
@@ -839,7 +908,7 @@ test "a cached positive response caps its stored ttls at the sanity ceiling" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, class); cache.put(1000, key, response, class);
// The over-ceiling record comes back at the ceiling; its shorter sibling is // The over-ceiling record comes back at the ceiling; its shorter sibling is
// untouched, because a downstream cache holds each record for its own TTL. // untouched, because a downstream cache holds each record for its own TTL.
@@ -856,7 +925,7 @@ test "a cached positive response caps its stored ttls at the sanity ceiling" {
} }
test "a positive response under the ceiling is stored byte for byte" { test "a positive response under the ceiling is stored byte for byte" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
@@ -864,7 +933,7 @@ test "a positive response under the ceiling is stored byte for byte" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, classify(response, 3600).?); cache.put(1000, key, response, classify(response, 3600).?);
// A TTL exactly at the ceiling is not rewritten, so a hit at elapsed zero // A TTL exactly at the ceiling is not rewritten, so a hit at elapsed zero
// returns the caller's bytes unchanged. // returns the caller's bytes unchanged.
@@ -873,7 +942,7 @@ test "a positive response under the ceiling is stored byte for byte" {
} }
test "a cached negative response serves the SOA ttl the entry expires at" { test "a cached negative response serves the SOA ttl the entry expires at" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
// The fixture's SOA record TTL is 3600 and its MINIMUM is 600, so the entry // The fixture's SOA record TTL is 3600 and its MINIMUM is 600, so the entry
@@ -884,7 +953,7 @@ test "a cached negative response serves the SOA ttl the entry expires at" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, nxdomain_bytes, class); cache.put(1000, key, nxdomain_bytes, class);
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
const fresh = cache.get(1000, key, &out).?; const fresh = cache.get(1000, key, &out).?;
@@ -900,38 +969,38 @@ test "a cached negative response serves the SOA ttl the entry expires at" {
} }
test "the stored SOA ttl follows negative_ttl_max and is never raised" { test "the stored SOA ttl follows negative_ttl_max and is never raised" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 120 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
try cache.put(0, key, nxdomain_bytes, classify(nxdomain_bytes, 120).?); cache.put(0, key, nxdomain_bytes, classify(nxdomain_bytes, 120).?);
try testing.expectEqual(@as(u32, 120), try firstAuthoritySoaTtl(cache.get(0, key, &out).?)); try testing.expectEqual(@as(u32, 120), try firstAuthoritySoaTtl(cache.get(0, key, &out).?));
// An SOA record TTL already below the entry's lifetime keeps its own value. // An SOA record TTL already below the entry's lifetime keeps its own value.
var short_buf: [nxdomain_bytes.len]u8 = undefined; var short_buf: [nxdomain_bytes.len]u8 = undefined;
const short = nxdomainWithSoaTtls(&short_buf, 60, 600); const short = nxdomainWithSoaTtls(&short_buf, 60, 600);
try cache.put(0, key, short, classify(short, 3600).?); cache.put(0, key, short, classify(short, 3600).?);
try testing.expectEqual(@as(u32, 60), try firstAuthoritySoaTtl(cache.get(0, key, &out).?)); try testing.expectEqual(@as(u32, 60), try firstAuthoritySoaTtl(cache.get(0, key, &out).?));
// A positive response has no SOA to clamp and keeps every answer TTL. // A positive response has no SOA to clamp and keeps every answer TTL.
var answer_buf: [512]u8 = undefined; var answer_buf: [512]u8 = undefined;
const answer = try buildAnswer(&answer_buf, &.{ 300, 900 }); const answer = try buildAnswer(&answer_buf, &.{ 300, 900 });
try cache.put(0, key, answer, classify(answer, 3600).?); cache.put(0, key, answer, classify(answer, 3600).?);
try testing.expectEqual(@as(u32, 300), try firstAnswerTtl(cache.get(0, key, &out).?)); try testing.expectEqual(@as(u32, 300), try firstAnswerTtl(cache.get(0, key, &out).?));
} }
test "an out buffer too small for the response is a miss" { test "an out buffer too small for the response is a miss" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{300}); const response = try buildAnswer(&response_buf, &.{300});
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?); cache.put(0, key, response, classify(response, 3600).?);
var small: [8]u8 = undefined; var small: [8]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(0, key, &small)); try testing.expectEqual(@as(?[]u8, null), cache.get(0, key, &small));
@@ -941,14 +1010,14 @@ test "an out buffer too small for the response is a miss" {
} }
test "an expired entry is a miss and is freed on access" { test "an expired entry is a miss and is freed on access" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{60}); const response = try buildAnswer(&response_buf, &.{60});
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, classify(response, 3600).?); cache.put(1000, key, response, classify(response, 3600).?);
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
try testing.expect(cache.get(1059, key, &out) != null); try testing.expect(cache.get(1059, key, &out) != null);
@@ -970,12 +1039,12 @@ test "aging that breaks the packet drops the entry once" {
"\x00\x00\x01\x00\x01\x00\x01\x41\x00\x00\x04\x01\x02\x03\x04" ++ "\x00\x00\x01\x00\x01\x00\x01\x41\x00\x00\x04\x01\x02\x03\x04" ++
"\xc0\x12\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x05\x06\x07\x08"; "\xc0\x12\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x05\x06\x07\x08";
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, poisoned, classify(poisoned, 3600).?); cache.put(0, key, poisoned, classify(poisoned, 3600).?);
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(1, key, &out)); try testing.expectEqual(@as(?[]u8, null), cache.get(1, key, &out));
@@ -985,7 +1054,7 @@ test "aging that breaks the packet drops the entry once" {
} }
test "CLOCK evicts the unreferenced entry and keeps the referenced one" { test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 2, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(2));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
@@ -999,13 +1068,13 @@ test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
const b = buildKey(&buf_b, "b.example.com", 1, 1, false, null); const b = buildKey(&buf_b, "b.example.com", 1, 1, false, null);
const c = buildKey(&buf_c, "c.example.com", 1, 1, false, null); const c = buildKey(&buf_c, "c.example.com", 1, 1, false, null);
try cache.put(0, a, response, class); cache.put(0, a, response, class);
try cache.put(0, b, response, class); cache.put(0, b, response, class);
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
try testing.expect(cache.get(0, a, &out) != null); try testing.expect(cache.get(0, a, &out) != null);
try cache.put(0, c, response, class); cache.put(0, c, response, class);
try testing.expectEqual(@as(u32, 2), cache.len()); try testing.expectEqual(@as(u32, 2), cache.len());
try testing.expectEqual(@as(u64, 1), cache.stats.evictions); try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
try testing.expect(cache.get(0, a, &out) != null); try testing.expect(cache.get(0, a, &out) != null);
@@ -1014,7 +1083,7 @@ test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
} }
test "replacing a key in place does not grow the cache" { test "replacing a key in place does not grow the cache" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
var first_buf: [512]u8 = undefined; var first_buf: [512]u8 = undefined;
@@ -1025,8 +1094,8 @@ test "replacing a key in place does not grow the cache" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, first, classify(first, 3600).?); cache.put(1000, key, first, classify(first, 3600).?);
try cache.put(2000, key, second, classify(second, 3600).?); cache.put(2000, key, second, classify(second, 3600).?);
try testing.expectEqual(@as(u32, 1), cache.len()); try testing.expectEqual(@as(u32, 1), cache.len());
try testing.expectEqual(@as(u64, 2), cache.stats.inserts); try testing.expectEqual(@as(u64, 2), cache.stats.inserts);
@@ -1040,7 +1109,7 @@ test "replacing a key in place does not grow the cache" {
} }
test "sweep removes only the expired entries" { test "sweep removes only the expired entries" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(8));
defer cache.deinit(); defer cache.deinit();
var short_buf: [512]u8 = undefined; var short_buf: [512]u8 = undefined;
@@ -1053,8 +1122,8 @@ test "sweep removes only the expired entries" {
const a = buildKey(&buf_a, "short.example.com", 1, 1, false, null); const a = buildKey(&buf_a, "short.example.com", 1, 1, false, null);
const b = buildKey(&buf_b, "long.example.com", 1, 1, false, null); const b = buildKey(&buf_b, "long.example.com", 1, 1, false, null);
try cache.put(0, a, short, classify(short, 3600).?); cache.put(0, a, short, classify(short, 3600).?);
try cache.put(0, b, long, classify(long, 3600).?); cache.put(0, b, long, classify(long, 3600).?);
try testing.expectEqual(@as(u32, 0), cache.sweep(59)); try testing.expectEqual(@as(u32, 0), cache.sweep(59));
try testing.expectEqual(@as(u32, 2), cache.len()); try testing.expectEqual(@as(u32, 2), cache.len());
@@ -1074,7 +1143,7 @@ test "sweep removes only the expired entries" {
test "a slot freed by eviction is reusable many times over" { test "a slot freed by eviction is reusable many times over" {
// The index deletes and reinserts on every eviction; this fails loudly if // The index deletes and reinserts on every eviction; this fails loudly if
// deletion ever stopped returning capacity to it. // deletion ever stopped returning capacity to it.
var cache = try DnsCache.init(testing.allocator, .{ .size = 2, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(2));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
@@ -1085,7 +1154,7 @@ test "a slot freed by eviction is reusable many times over" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
for (0..500) |i| { for (0..500) |i| {
const qname = try std.fmt.bufPrint(&name_buf, "host{d}.example.com", .{i}); const qname = try std.fmt.bufPrint(&name_buf, "host{d}.example.com", .{i});
try cache.put(0, buildKey(&key_buf, qname, 1, 1, false, null), response, class); cache.put(0, buildKey(&key_buf, qname, 1, 1, false, null), response, class);
try testing.expect(cache.len() <= 2); try testing.expect(cache.len() <= 2);
} }
try testing.expectEqual(@as(u32, 2), cache.len()); try testing.expectEqual(@as(u32, 2), cache.len());
@@ -1093,7 +1162,7 @@ test "a slot freed by eviction is reusable many times over" {
} }
test "a cache of zero entries stores nothing" { test "a cache of zero entries stores nothing" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 0, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(0));
defer cache.deinit(); defer cache.deinit();
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
@@ -1101,7 +1170,7 @@ test "a cache of zero entries stores nothing" {
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?); cache.put(0, key, response, classify(response, 3600).?);
try testing.expectEqual(@as(u32, 0), cache.len()); try testing.expectEqual(@as(u32, 0), cache.len());
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
@@ -1109,7 +1178,7 @@ test "a cache of zero entries stores nothing" {
} }
test "memoryBytes covers the slots, the responses and the index" { test "memoryBytes covers the slots, the responses and the index" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
const empty_bytes = cache.memoryBytes(); const empty_bytes = cache.memoryBytes();
@@ -1119,23 +1188,151 @@ test "memoryBytes covers the slots, the responses and the index" {
const response = try buildAnswer(&response_buf, &.{300}); const response = try buildAnswer(&response_buf, &.{300});
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?); cache.put(0, key, response, classify(response, 3600).?);
try testing.expectEqual(empty_bytes + response.len, cache.memoryBytes()); try testing.expectEqual(empty_bytes + response.len, cache.memoryBytes());
} }
fn initAndPut(gpa: Allocator, response: []const u8) !void { fn initUnderFailure(gpa: Allocator) !void {
var cache = try DnsCache.init(gpa, .{ .size = 16, .negative_ttl_max = 3600 }); var cache = try DnsCache.init(gpa, .sized(16));
cache.deinit();
}
test "init survives every allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, initUnderFailure, .{});
}
fn keyFor(buf: *[max_key_len]u8, domain: []const u8) []const u8 {
return buildKey(buf, domain, 1, 1, false, null);
}
const positive_300: Class = .{ .ttl_seconds = 300, .negative = false };
test "puts beyond the byte budget evict, and the live bytes never exceed it" {
var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{300});
var cache = try DnsCache.init(testing.allocator, .{
.size = 16,
.max_bytes = @intCast(3 * response.len),
});
defer cache.deinit();
var name_buf: [32]u8 = undefined;
var key_buf: [max_key_len]u8 = undefined;
for (0..10) |i| {
const domain = try std.fmt.bufPrint(&name_buf, "n{d}.example.com", .{i});
cache.put(0, keyFor(&key_buf, domain), response, positive_300);
try testing.expect(cache.live_bytes <= cache.max_bytes);
}
// The entry count never bound: the bytes did.
try testing.expectEqual(@as(u32, 3), cache.len());
try testing.expectEqual(@as(u64, 7), cache.stats.evictions);
try testing.expectEqual(3 * response.len, cache.live_bytes);
}
test "a response larger than the whole budget is skipped, counted, and displaces nothing" {
var small_buf: [512]u8 = undefined;
const small = try buildAnswer(&small_buf, &.{300});
var large_buf: [512]u8 = undefined;
const large = try buildAnswer(&large_buf, &.{ 300, 300, 300, 300 });
var cache = try DnsCache.init(testing.allocator, .{
.size = 4,
.max_bytes = @intCast(small.len),
});
defer cache.deinit(); defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined; var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null); const key = keyFor(&key_buf, "example.com");
try cache.put(0, key, response, classify(response, 3600).?); cache.put(0, key, small, positive_300);
try cache.put(0, key, response, classify(response, 3600).?); cache.put(0, key, large, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.oversized);
var out: [512]u8 = undefined;
try testing.expectEqualSlices(u8, small, cache.get(0, key, &out).?);
} }
test "init and put survive every allocation failure" { test "a replacement the allocator refuses keeps the old entry and is counted" {
var first_buf: [512]u8 = undefined;
const first = try buildAnswer(&first_buf, &.{300});
var second_buf: [512]u8 = undefined;
const second = try buildAnswer(&second_buf, &.{ 300, 300 });
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{});
var cache = try DnsCache.init(failing.allocator(), .sized(4));
defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined;
const key = keyFor(&key_buf, "example.com");
cache.put(0, key, first, positive_300);
failing.fail_index = failing.alloc_index;
cache.put(0, key, second, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.copy_failures);
try testing.expectEqual(first.len, cache.live_bytes);
var out: [512]u8 = undefined;
try testing.expectEqualSlices(u8, first, cache.get(0, key, &out).?);
}
test "a replacement that needs room evicts others, never itself" {
var small_buf: [512]u8 = undefined;
const small = try buildAnswer(&small_buf, &.{300});
var large_buf: [512]u8 = undefined;
const large = try buildAnswer(&large_buf, &.{ 300, 300 });
var cache = try DnsCache.init(testing.allocator, .{
.size = 4,
.max_bytes = @intCast(small.len + large.len - 1),
});
defer cache.deinit();
var key_a: [max_key_len]u8 = undefined;
var key_b: [max_key_len]u8 = undefined;
const a = keyFor(&key_a, "a.example.com");
const b = keyFor(&key_b, "b.example.com");
cache.put(0, a, small, positive_300);
cache.put(0, b, small, positive_300);
// Growing `b` to `large` no longer fits beside `a`.
cache.put(0, b, large, positive_300);
var out: [512]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(0, a, &out));
try testing.expectEqualSlices(u8, large, cache.get(0, b, &out).?);
try testing.expectEqual(large.len, cache.live_bytes);
}
test "an insert takes a free slot instead of evicting a live entry" {
var response_buf: [512]u8 = undefined; var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{300}); const response = try buildAnswer(&response_buf, &.{300});
try testing.checkAllAllocationFailures(testing.allocator, initAndPut, .{response}); var short_buf: [512]u8 = undefined;
const short = try buildAnswer(&short_buf, &.{10});
var cache = try DnsCache.init(testing.allocator, .sized(3));
defer cache.deinit();
var keys: [5][max_key_len]u8 = undefined;
const a = keyFor(&keys[0], "a.example.com");
const b = keyFor(&keys[1], "b.example.com");
const c = keyFor(&keys[2], "c.example.com");
const d = keyFor(&keys[3], "d.example.com");
const e = keyFor(&keys[4], "e.example.com");
cache.put(0, a, response, positive_300);
cache.put(0, b, response, positive_300);
cache.put(0, c, short, .{ .ttl_seconds = 10, .negative = false });
// Full: `d` evicts `a`, and the hand moves on to `b`.
cache.put(0, d, response, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
// `c` expires, freeing a slot behind the hand.
var out: [512]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(20, c, &out));
cache.put(20, e, response, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
try testing.expect(cache.get(20, b, &out) != null);
try testing.expect(cache.get(20, d, &out) != null);
try testing.expect(cache.get(20, e, &out) != null);
} }
+39 -17
View File
@@ -937,14 +937,14 @@ const Context = struct {
/// A response the cache declines to hold, or an allocation it cannot make, /// 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` /// 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 { fn cachePut(ctx: *Context, key: []const u8, message: []const u8) void {
const class = dns_cache.classify(message, ctx.policy.negative_ttl_max) orelse return; const class = dns_cache.classify(message, ctx.policy.negative_ttl_max) orelse return;
ctx.handler.cache_mutex.lockUncancelable(ctx.io); ctx.handler.cache_mutex.lockUncancelable(ctx.io);
defer ctx.handler.cache_mutex.unlock(ctx.io); defer ctx.handler.cache_mutex.unlock(ctx.io);
const cache = ctx.handler.cache orelse return; 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 /// 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); 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(); defer cache.deinit();
// The name is blocklisted, so only the zone path can answer it from the // 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, false,
null, 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; var buf: [udp_limit_min]u8 = undefined;
const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf))); 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 ----------------------------------------------------------------- // --- 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" { test "a miss stores the answer and the next query is a hit with a fresh id" {
var t: TestIo = .init(); var t: TestIo = .init();
defer t.deinit(); 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(); defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes }; 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(); defer t.deinit();
const io = t.io(); const io = t.io();
var retired_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, .{ .size = 8, .negative_ttl_max = 3600 }); var live_cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer live_cache.deinit(); defer live_cache.deinit();
var retired_limiter: rate_limiter.RateLimiter = try .init( var retired_limiter: rate_limiter.RateLimiter = try .init(
@@ -2914,7 +2936,7 @@ test "a blocked answer is never cached" {
var mgr: manager.Manager = undefined; var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot); 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(); defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes }; 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(); var t: TestIo = .init();
defer t.deinit(); 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(); defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes }; 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; var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot); 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(); defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes }; 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; var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot); 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(); defer cache.deinit();
var queue_buf: [8]logger_mod.Entry = undefined; 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); 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(); defer cache.deinit();
var snapshot = try provenanceSnapshot(&.{}); var snapshot = try provenanceSnapshot(&.{});
@@ -3586,7 +3608,7 @@ test "a local record and a forward zone are explained with no upstream named" {
false, false,
null, null,
); );
try cache.put( cache.put(
std.Io.Clock.real.now(io).toSeconds(), std.Io.Clock.real.now(io).toSeconds(),
key, key,
b.finish(), 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); 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(); defer cache.deinit();
var snapshot = try provenanceSnapshot(&.{}); 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(); var t: TestIo = .init();
defer t.deinit(); 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(); defer cache.deinit();
// An entry stored under the subnet-free key every other client shares. // 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, false,
null, null,
); );
try cache.put( cache.put(
std.Io.Clock.real.now(t.io()).toSeconds(), std.Io.Clock.real.now(t.io()).toSeconds(),
global_key, global_key,
planted, planted,
@@ -4494,7 +4516,7 @@ test "forward mode refuses to cache a query carrying two subnets" {
var t: TestIo = .init(); var t: TestIo = .init();
defer t.deinit(); 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(); defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes }; var fake: FakeUpstream = .{ .reply = response_bytes };
+3 -3
View File
@@ -486,7 +486,7 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and
var mgr: manager.Manager = undefined; var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot); fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 }); var cache: dns_cache.DnsCache = try .init(gpa, .sized(8));
defer cache.deinit(); defer cache.deinit();
var fake: FakeUpstream = .{ .reply = .a }; var fake: FakeUpstream = .{ .reply = .a };
@@ -540,7 +540,7 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
defer threaded.deinit(); defer threaded.deinit();
const io = threaded.io(); const io = threaded.io();
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 }); var cache: dns_cache.DnsCache = try .init(gpa, .sized(8));
defer cache.deinit(); defer cache.deinit();
var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
@@ -595,7 +595,7 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
null, null,
); );
const aged_by = 10; const aged_by = 10;
try cache.put( cache.put(
std.Io.Clock.real.now(io).toSeconds() - aged_by, std.Io.Clock.real.now(io).toSeconds() - aged_by,
key, key,
b.finish(), b.finish(),
+2 -2
View File
@@ -604,13 +604,13 @@ test "S8 case 8: the cache ages a real response and expires it at the boundary"
try testing.expectEqual(@as(u32, 300), class.ttl_seconds); try testing.expectEqual(@as(u32, 300), class.ttl_seconds);
try testing.expectEqual(false, class.negative); try testing.expectEqual(false, class.negative);
var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 16, .negative_ttl_max = 3600 }); var cache = try dns_cache.DnsCache.init(testing.allocator, .sized(16));
defer cache.deinit(); defer cache.deinit();
var key_buf: [dns_cache.max_key_len]u8 = undefined; var key_buf: [dns_cache.max_key_len]u8 = undefined;
const key = dns_cache.buildKey(&key_buf, "example.com", 1, 1, false, null); const key = dns_cache.buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, class); cache.put(1000, key, response, class);
try testing.expectEqual(@as(u32, 1), cache.len()); try testing.expectEqual(@as(u32, 1), cache.len());
var out: [512]u8 = undefined; var out: [512]u8 = undefined;
+1 -1
View File
@@ -326,7 +326,7 @@ pub const Plan = struct {
if (self.ops.contains(.cache)) { if (self.ops.contains(.cache)) {
const candidate = try gpa.create(dns_cache.DnsCache); const candidate = try gpa.create(dns_cache.DnsCache);
candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| { candidate.* = dns_cache.DnsCache.init(gpa, .sized(self.cfg.cache.size)) catch |err| {
gpa.destroy(candidate); gpa.destroy(candidate);
return err; return err;
}; };
+28 -2
View File
@@ -67,6 +67,8 @@ pub const CacheSample = struct {
stats: dns_cache.Stats, stats: dns_cache.Stats,
entries: u64, entries: u64,
memory_bytes: u64, memory_bytes: u64,
response_bytes: u64,
max_bytes: u64,
}; };
pub const LimiterSample = struct { pub const LimiterSample = struct {
@@ -226,6 +228,8 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
.stats = cache.stats, .stats = cache.stats,
.entries = cache.len(), .entries = cache.len(),
.memory_bytes = cache.memoryBytes(), .memory_bytes = cache.memoryBytes(),
.response_bytes = cache.live_bytes,
.max_bytes = cache.max_bytes,
}; };
} }
@@ -414,6 +418,13 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
try counterGroup(w, "nxdns_cache_", "DNS cache counter", cache.stats); try counterGroup(w, "nxdns_cache_", "DNS cache counter", cache.stats);
try gauge(w, "nxdns_cache_entries", "Responses currently held in the DNS cache.", cache.entries); try gauge(w, "nxdns_cache_entries", "Responses currently held in the DNS cache.", cache.entries);
try gauge(w, "nxdns_cache_memory_bytes", "Bytes held by the DNS cache.", cache.memory_bytes); try gauge(w, "nxdns_cache_memory_bytes", "Bytes held by the DNS cache.", cache.memory_bytes);
try gauge(
w,
"nxdns_cache_response_bytes",
"Bytes of cached responses, the part nxdns_cache_max_bytes bounds.",
cache.response_bytes,
);
try gauge(w, "nxdns_cache_max_bytes", "The DNS cache's response byte budget.", cache.max_bytes);
} }
if (sample.limiter) |limiter| { if (sample.limiter) |limiter| {
@@ -873,9 +884,20 @@ test "a full sample renders the whole exposition, byte for byte" {
.logger = .{ .queries_dropped = 1, .rows_written = 40, .batches_gated = 2 }, .logger = .{ .queries_dropped = 1, .rows_written = 40, .batches_gated = 2 },
.log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 }, .log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 },
.cache = .{ .cache = .{
.stats = .{ .hits = 7, .misses = 8, .inserts = 6, .evictions = 1, .expirations = 2, .invalid_hits = 0 }, .stats = .{
.hits = 7,
.misses = 8,
.inserts = 6,
.evictions = 1,
.expirations = 2,
.invalid_hits = 0,
.oversized = 3,
.copy_failures = 4,
},
.entries = 5, .entries = 5,
.memory_bytes = 4096, .memory_bytes = 4096,
.response_bytes = 512,
.max_bytes = 1024,
}, },
.limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 }, .limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 },
.tracker = .{ .tracker = .{
@@ -915,6 +937,10 @@ test "a full sample renders the whole exposition, byte for byte" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_hits_total 7\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_hits_total 7\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_entries 5\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_entries 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_memory_bytes 4096\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_memory_bytes 4096\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_response_bytes 512\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_max_bytes 1024\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_oversized_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_copy_failures_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_refused_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_refused_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n"));
@@ -1733,7 +1759,7 @@ test "collect reads the live counters of the components it is given" {
defer threaded.deinit(); defer threaded.deinit();
const io = threaded.io(); const io = threaded.io();
var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 4 }); var cache = try dns_cache.DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit(); defer cache.deinit();
cache.stats.hits = 11; cache.stats.hits = 11;
cache.stats.misses = 5; cache.stats.misses = 5;
+1 -1
View File
@@ -625,7 +625,7 @@ const Env = struct {
self.sink = .init(&self.log_controller, self.hub); self.sink = .init(&self.log_controller, self.hub);
self.cache = try gpa.create(dns_cache.DnsCache); self.cache = try gpa.create(dns_cache.DnsCache);
self.cache.* = try .init(gpa, .{}); self.cache.* = try .init(gpa, .sized((model.Cache{}).size));
self.dns_limiter = try gpa.create(rate_limiter.RateLimiter); self.dns_limiter = try gpa.create(rate_limiter.RateLimiter);
self.dns_limiter.* = try .init(gpa, .{ .limit = 1000, .window_seconds = 60 }); self.dns_limiter.* = try .init(gpa, .{ .limit = 1000, .window_seconds = 60 });