218 lines
7.7 KiB
Zig
218 lines
7.7 KiB
Zig
//! 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"));
|
|
}
|