//! The published local-answer tables: the compiled local records and the //! compiled forward zones the query path reads (milestone-8 ruling 12). //! //! Both tables are immutable once built, so publishing a new one is a pointer //! swap under an `std.Io.RwLock` — the blocklist manager's pattern at a much //! smaller scale, and for the same reason: a shared lock held for the //! microseconds of one lookup costs an uncontended atomic pair, and reclaiming //! the old table without a lock would need epoch tracking this project has no //! use for. //! //! The two tables live under one lock because one API call can change either //! and the query path reads both in sequence. Two locks would double the cost //! of every query to buy nothing. //! //! `acquire` brackets exactly one query. The handle borrows the live fields, so //! it must not outlive its `release` — which is why `swap` may free the tables //! it replaced as soon as it has the exclusive lock: no reader can still be //! holding them. const std = @import("std"); const Allocator = std.mem.Allocator; const forward_zones = @import("../local/forward_zones.zig"); const records = @import("../local/records.zig"); pub const LocalTables = struct { lock: std.Io.RwLock = .init, records: records.Records = .empty, zones: forward_zones.Zones = .empty, /// No records and no zones: every name goes to the filtering path. pub const empty: LocalTables = .{}; /// Reader side of the swap. The pointers are the live fields, so release /// the handle before the query ends and do not retain them. pub const Handle = struct { records: *const records.Records, zones: *const forward_zones.Zones, tables: *LocalTables, pub fn release(self: Handle, io: std.Io) void { self.tables.lock.unlockShared(io); } }; /// Uncancelable, like the manager's: the critical section is a lookup with /// no socket and no file in it, so it always completes. pub fn acquire(self: *LocalTables, io: std.Io) Handle { self.lock.lockSharedUncancelable(io); return .{ .records = &self.records, .zones = &self.zones, .tables = self }; } /// Publishes `new_records` and `new_zones` and frees the tables they /// replace. Both are installed together, so no query can see the records of /// one generation beside the zones of another. /// /// The caller built both tables with `gpa` and hands ownership over here. pub fn swap( self: *LocalTables, io: std.Io, gpa: Allocator, new_records: records.Records, new_zones: forward_zones.Zones, ) void { self.lock.lockUncancelable(io); var old_records = self.records; var old_zones = self.zones; self.records = new_records; self.zones = new_zones; // Freed while the exclusive lock is held: every reader that could hold // the old tables released its shared lock before this one was granted. old_records.deinit(gpa); old_zones.deinit(gpa); self.lock.unlock(io); } /// Frees the published tables. The caller must have stopped every reader /// first, exactly as it must before releasing any other borrowed collaborator. pub fn deinit(self: *LocalTables, gpa: Allocator) void { self.records.deinit(gpa); self.zones.deinit(gpa); self.* = .empty; } }; // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; const TestIo = struct { threaded: std.Io.Threaded, fn init() TestIo { return .{ .threaded = .init(testing.allocator, .{}) }; } fn io(self: *TestIo) std.Io { return self.threaded.io(); } fn deinit(self: *TestIo) void { self.threaded.deinit(); } }; fn buildRecords(name: []const u8, value: []const u8) !records.Records { return records.Records.build(testing.allocator, &.{ .{ .name = name, .rtype = .a, .value = value, .ttl = 60 }, }); } fn buildZones(zone: []const u8) !forward_zones.Zones { return forward_zones.Zones.build(testing.allocator, &.{ .{ .zone = zone, .resolver = "udp://10.0.0.1:53" }, }); } test "an empty holder answers nothing and frees nothing" { var t: TestIo = .init(); defer t.deinit(); var tables: LocalTables = .empty; defer tables.deinit(testing.allocator); const handle = tables.acquire(t.io()); defer handle.release(t.io()); try testing.expect(!handle.records.hasName("nas.lan")); try testing.expectEqual(@as(?*const forward_zones.Zone, null), handle.zones.match("nas.lan")); } test "a swap publishes both tables together" { var t: TestIo = .init(); defer t.deinit(); var tables: LocalTables = .empty; defer tables.deinit(testing.allocator); tables.swap(t.io(), testing.allocator, try buildRecords("nas.lan", "192.168.1.10"), try buildZones("lan")); const handle = tables.acquire(t.io()); defer handle.release(t.io()); try testing.expect(handle.records.hasName("nas.lan")); try testing.expect(handle.zones.match("nas.lan") != null); } test "a second swap frees the tables it replaces" { var t: TestIo = .init(); defer t.deinit(); var tables: LocalTables = .empty; defer tables.deinit(testing.allocator); tables.swap(t.io(), testing.allocator, try buildRecords("old.lan", "192.168.1.10"), try buildZones("old")); tables.swap(t.io(), testing.allocator, try buildRecords("new.lan", "192.168.1.11"), try buildZones("new")); const handle = tables.acquire(t.io()); defer handle.release(t.io()); try testing.expect(!handle.records.hasName("old.lan")); try testing.expect(handle.records.hasName("new.lan")); try testing.expect(handle.zones.match("host.old") == null); try testing.expect(handle.zones.match("host.new") != null); } test "a handle keeps reading the generation it acquired" { var t: TestIo = .init(); defer t.deinit(); var tables: LocalTables = .empty; defer tables.deinit(testing.allocator); tables.swap(t.io(), testing.allocator, try buildRecords("first.lan", "192.168.1.10"), .empty); const handle = tables.acquire(t.io()); try testing.expect(handle.records.hasName("first.lan")); // Reading twice under one handle must give one answer, which is the whole // point of bracketing a query rather than each lookup. try testing.expect(handle.records.hasName("first.lan")); handle.release(t.io()); tables.swap(t.io(), testing.allocator, try buildRecords("second.lan", "192.168.1.11"), .empty); const after = tables.acquire(t.io()); defer after.release(t.io()); try testing.expect(after.records.hasName("second.lan")); } test "a swap waits for a live reader and the reader sees the new tables next time" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var tables: LocalTables = .empty; defer tables.deinit(testing.allocator); const Swapper = struct { fn run(target: *LocalTables, inner: std.Io, gpa: Allocator) void { const built = records.Records.build(gpa, &.{ .{ .name = "swapped.lan", .rtype = .a, .value = "192.168.1.12", .ttl = 60 }, }) catch return; target.swap(inner, gpa, built, .empty); } }; const handle = tables.acquire(io); var group: std.Io.Group = .init; try group.concurrent(io, Swapper.run, .{ &tables, io, testing.allocator }); try testing.expect(!handle.records.hasName("swapped.lan")); handle.release(io); try group.await(io); const after = tables.acquire(io); defer after.release(io); try testing.expect(after.records.hasName("swapped.lan")); }