milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
//! 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user