diff --git a/CHANGELOG.md b/CHANGELOG.md index 9516833..4ce0853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Sections are written by hand. Nothing here is generated from commit messages: th ### 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 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 `", 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. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1d7e5a2..5b9a34a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -64,7 +64,7 @@ What a blocked query gets back. | 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. | The cache cannot be turned off; the smallest is `cache.size = 1`. diff --git a/specs/memory-budget.md b/specs/memory-budget.md index e81f13c..264c5bf 100644 --- a/specs/memory-budget.md +++ b/specs/memory-budget.md @@ -75,11 +75,11 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x ### 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. -- 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 -- 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. - 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. diff --git a/src/app.zig b/src/app.zig index 27df25b..b9e62e5 100644 --- a/src/app.zig +++ b/src/app.zig @@ -718,7 +718,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { const both = blk: { const c = try gpa.create(dns_cache.DnsCache); errdefer gpa.destroy(c); - c.* = try .init(gpa, cfg.cache); + c.* = try .init(gpa, .sized(cfg.cache.size)); errdefer c.deinit(); const l = try gpa.create(rate_limiter.RateLimiter); diff --git a/src/cache/dns_cache.zig b/src/cache/dns_cache.zig index 2eff051..25385fb 100644 --- a/src/cache/dns_cache.zig +++ b/src/cache/dns_cache.zig @@ -21,7 +21,6 @@ const std = @import("std"); const packet = @import("../dns/packet.zig"); const record = @import("../dns/record.zig"); const types = @import("../dns/types.zig"); -const model = @import("../config/model.zig"); const Allocator = std.mem.Allocator; @@ -244,9 +243,27 @@ fn soaTtls(p: packet.Packet) ?SoaTtls { return null; } -/// Field-for-field the cache section of the config model. Aliasing it keeps one -/// type for one concept: `size` counts entries, not bytes (ruling 7). -pub const Config = model.Cache; +pub const Config = struct { + /// Entries, not bytes (ruling 7): `cache.size`. + 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 { hits: u64 = 0, @@ -257,6 +274,11 @@ pub const Stats = struct { /// Entries dropped because aging their bytes made them unparsable. A /// poisoned entry never serves twice. 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 @@ -271,12 +293,16 @@ const Slot = struct { negative: bool, referenced: 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 { 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, /// which is allocated once and never moves. const Index = std.StringHashMapUnmanaged(u32); @@ -288,6 +314,12 @@ pub const DnsCache = struct { /// The CLOCK hand: the next slot the replacement scan will examine. hand: 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, /// 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 { const slots = try gpa.alloc(Slot, config.size); errdefer gpa.free(slots); - for (slots) |*slot| { + for (slots, 0..) |*slot, i| { slot.* = .{ .key = undefined, .key_len = 0, @@ -306,6 +338,7 @@ pub const DnsCache = struct { .negative = false, .referenced = 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, .hand = 0, .occupied = 0, + .free_head = if (slots.len == 0) no_slot else 0, + .max_bytes = config.max_bytes, + .live_bytes = 0, .stats = .{}, }; } @@ -334,42 +370,59 @@ pub const DnsCache = struct { /// Stores a clone of `response` under `key`. A key already present is /// 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 /// 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 /// caller's buffer is not touched. /// - /// A cache configured with zero entries stores nothing. That is caching - /// switched off, not a failure, so it is not an error. + /// Nothing here fails the query that produced `response`, which has already + /// 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( self: *DnsCache, now_s: i64, key: []const u8, response: []const u8, class: Class, - ) Allocator.Error!void { + ) void { std.debug.assert(key.len <= max_key_len); 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); - errdefer self.gpa.free(bytes); + // Cloned before anything is released, so a refused allocation leaves a + // 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); const expires_at_s = now_s +| @as(i64, class.ttl_seconds); if (self.index.get(key)) |existing| { const slot = &self.slots[existing]; + self.live_bytes -= slot.bytes.len; self.gpa.free(slot.bytes); + slot.bytes = &.{}; + self.reclaimBytes(now_s, bytes.len, existing); slot.bytes = bytes; slot.stored_at_s = now_s; slot.expires_at_s = expires_at_s; slot.negative = class.negative; + self.live_bytes += bytes.len; self.stats.inserts += 1; return; } + self.reclaimBytes(now_s, bytes.len, null); const index = self.acquireSlot(now_s); const slot = &self.slots[index]; @memcpy(slot.key[0..key.len], key); @@ -387,6 +440,7 @@ pub const DnsCache = struct { gop.value_ptr.* = index; self.occupied += 1; + self.live_bytes += bytes.len; 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 /// metadata byte per bucket. pub fn memoryBytes(self: *const DnsCache) usize { - var total = self.slots.len * @sizeOf(Slot); - for (self.slots) |*slot| { - if (slot.occupied) total += slot.bytes.len; - } - total += @as(usize, self.index.capacity()) * - (@sizeOf([]const u8) + @sizeOf(u32) + 1); - return total; + return self.slots.len * @sizeOf(Slot) + self.live_bytes + + @as(usize, self.index.capacity()) * (@sizeOf([]const u8) + @sizeOf(u32) + 1); } - /// 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 eviction: it was already dead, and counting it as an eviction would /// 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); - // Every occupied slot is examined at most twice: the first pass clears - // the reference bits, the second finds a slot with none set. + // Every candidate is examined at most twice: the first pass clears the + // 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; var steps: usize = 0; @@ -487,11 +553,11 @@ pub const DnsCache = struct { self.hand = (self.hand + 1) % capacity; const slot = &self.slots[index]; - if (!slot.occupied) return index; + if (!slot.occupied or index == keep) continue; if (now_s >= slot.expires_at_s) { self.release(index); self.stats.expirations += 1; - return index; + return; } if (slot.referenced) { slot.referenced = false; @@ -499,7 +565,7 @@ pub const DnsCache = struct { } self.release(index); self.stats.evictions += 1; - return index; + return; } unreachable; } @@ -509,11 +575,14 @@ pub const DnsCache = struct { std.debug.assert(slot.occupied); const removed = self.index.remove(slot.keySlice()); std.debug.assert(removed); + self.live_bytes -= slot.bytes.len; self.gpa.free(slot.bytes); slot.bytes = &.{}; slot.key_len = 0; slot.occupied = false; slot.referenced = false; + slot.next_free = self.free_head; + self.free_head = index; self.occupied -= 1; } }; @@ -798,7 +867,7 @@ test "classify refuses every other rcode" { } 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(); 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; 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()); 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" { - 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(); 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; 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 // 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" { - 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(); 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; 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 // 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" { - 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(); // 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; 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; 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" { - 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(); var key_buf: [max_key_len]u8 = undefined; const key = buildKey(&key_buf, "example.com", 1, 1, false, null); 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).?)); // An SOA record TTL already below the entry's lifetime keeps its own value. var short_buf: [nxdomain_bytes.len]u8 = undefined; 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).?)); // A positive response has no SOA to clamp and keeps every answer TTL. var answer_buf: [512]u8 = undefined; 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).?)); } 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(); var response_buf: [512]u8 = undefined; const response = try buildAnswer(&response_buf, &.{300}); var key_buf: [max_key_len]u8 = undefined; 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; 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" { - 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(); var response_buf: [512]u8 = undefined; const response = try buildAnswer(&response_buf, &.{60}); var key_buf: [max_key_len]u8 = undefined; 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; 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" ++ "\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(); var key_buf: [max_key_len]u8 = undefined; 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; 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" { - 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(); 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 c = buildKey(&buf_c, "c.example.com", 1, 1, false, null); - try cache.put(0, a, response, class); - try cache.put(0, b, response, class); + cache.put(0, a, response, class); + cache.put(0, b, response, class); var out: [512]u8 = undefined; 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(u64, 1), cache.stats.evictions); 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" { - 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(); 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; const key = buildKey(&key_buf, "example.com", 1, 1, false, null); - try cache.put(1000, key, first, classify(first, 3600).?); - try cache.put(2000, key, second, classify(second, 3600).?); + cache.put(1000, key, first, classify(first, 3600).?); + cache.put(2000, key, second, classify(second, 3600).?); try testing.expectEqual(@as(u32, 1), cache.len()); 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" { - 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(); 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 b = buildKey(&buf_b, "long.example.com", 1, 1, false, null); - try cache.put(0, a, short, classify(short, 3600).?); - try cache.put(0, b, long, classify(long, 3600).?); + cache.put(0, a, short, classify(short, 3600).?); + cache.put(0, b, long, classify(long, 3600).?); try testing.expectEqual(@as(u32, 0), cache.sweep(59)); 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" { // The index deletes and reinserts on every eviction; this fails loudly if // 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(); 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; for (0..500) |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.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" { - 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(); 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; 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()); 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" { - 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(); 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}); var key_buf: [max_key_len]u8 = undefined; 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()); } -fn initAndPut(gpa: Allocator, response: []const u8) !void { - var cache = try DnsCache.init(gpa, .{ .size = 16, .negative_ttl_max = 3600 }); +fn initUnderFailure(gpa: Allocator) !void { + 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(); var key_buf: [max_key_len]u8 = undefined; - const key = buildKey(&key_buf, "example.com", 1, 1, false, null); - try cache.put(0, key, response, classify(response, 3600).?); - try cache.put(0, key, response, classify(response, 3600).?); + const key = keyFor(&key_buf, "example.com"); + cache.put(0, key, small, positive_300); + 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; 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); } diff --git a/src/server/handler.zig b/src/server/handler.zig index 99d9c99..22c3064 100644 --- a/src/server/handler.zig +++ b/src/server/handler.zig @@ -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 }; diff --git a/src/server/phase7_integration_test.zig b/src/server/phase7_integration_test.zig index 0c9fe6d..99a686a 100644 --- a/src/server/phase7_integration_test.zig +++ b/src/server/phase7_integration_test.zig @@ -486,7 +486,7 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and var mgr: manager.Manager = undefined; 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(); 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(); 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(); 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, ); const aged_by = 10; - try cache.put( + cache.put( std.Io.Clock.real.now(io).toSeconds() - aged_by, key, b.finish(), diff --git a/src/storage/phase6_integration_test.zig b/src/storage/phase6_integration_test.zig index 364244f..6860887 100644 --- a/src/storage/phase6_integration_test.zig +++ b/src/storage/phase6_integration_test.zig @@ -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(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(); var key_buf: [dns_cache.max_key_len]u8 = undefined; 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()); var out: [512]u8 = undefined; diff --git a/src/web/handlers/apply.zig b/src/web/handlers/apply.zig index 96e5c63..dd1732a 100644 --- a/src/web/handlers/apply.zig +++ b/src/web/handlers/apply.zig @@ -326,7 +326,7 @@ pub const Plan = struct { if (self.ops.contains(.cache)) { 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); return err; }; diff --git a/src/web/metrics.zig b/src/web/metrics.zig index 90ae93f..d9d8599 100644 --- a/src/web/metrics.zig +++ b/src/web/metrics.zig @@ -67,6 +67,8 @@ pub const CacheSample = struct { stats: dns_cache.Stats, entries: u64, memory_bytes: u64, + response_bytes: u64, + max_bytes: u64, }; pub const LimiterSample = struct { @@ -226,6 +228,8 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator. .stats = cache.stats, .entries = cache.len(), .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 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_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| { @@ -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 }, .log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 }, .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, .memory_bytes = 4096, + .response_bytes = 512, + .max_bytes = 1024, }, .limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 }, .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_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_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_tracked_clients 3\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(); 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(); cache.stats.hits = 11; cache.stats.misses = 5; diff --git a/src/web/web_integration_test.zig b/src/web/web_integration_test.zig index 9f0d3fa..b587c39 100644 --- a/src/web/web_integration_test.zig +++ b/src/web/web_integration_test.zig @@ -625,7 +625,7 @@ const Env = struct { self.sink = .init(&self.log_controller, self.hub); 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 .init(gpa, .{ .limit = 1000, .window_seconds = 60 });