milestone 25: client names learned over reverse dns
Gates / test (push) Successful in 2m58s
Gates / frontend (push) Successful in 3m57s
Gates / test-aarch64 (push) Successful in 8m20s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 19m9s

This commit is contained in:
2026-08-15 11:18:44 +02:00
parent c428bc2398
commit 3c794b645b
20 changed files with 2638 additions and 36 deletions
File diff suppressed because it is too large Load Diff
+167 -26
View File
@@ -21,6 +21,7 @@
const std = @import("std");
const address = @import("../platform/address.zig");
const client_names = @import("client_names.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
@@ -135,6 +136,7 @@ pub const Tracker = struct {
io: std.Io,
database: *db.Db,
monitor: ?*disk_monitor.Monitor,
names: ?*client_names.Resolver,
) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(flush_interval_s),
@@ -143,12 +145,18 @@ pub const Tracker = struct {
while (true) {
try interval.sleep(io);
const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
self.flushOnce(io, database, writes_allowed);
self.flushOnce(io, database, writes_allowed, names);
}
}
/// One pass: drain the table, write a row per client, and prune on every
/// `prune_every_passes`-th pass.
/// One pass: drain the table, write a row per client, prune on every
/// `prune_every_passes`-th pass, and then learn names for the rows that
/// have none (milestone-25 ruling 1).
///
/// The order of the three steps is fixed and one `now_s` serves all three.
/// Resolving before pruning would spend PTR queries on rows the same pass
/// deletes; resolving before the drain would make a slow resolver delay the
/// writes the pass exists for.
///
/// A gated pass does nothing at all, not even count: the work it skipped is
/// still owed, and the pending addresses it leaves behind are re-tracked by
@@ -161,9 +169,17 @@ pub const Tracker = struct {
/// Only `run` may call this concurrently with itself — the drain buffer is
/// this call's stack, but the pass counter and the prune schedule assume a
/// single caller.
pub fn flushOnce(self: *Tracker, io: std.Io, database: *db.Db, writes_allowed: bool) void {
pub fn flushOnce(
self: *Tracker,
io: std.Io,
database: *db.Db,
writes_allowed: bool,
names: ?*client_names.Resolver,
) void {
// A gated pass skips naming too: naming writes.
if (!writes_allowed) return;
const now_s = std.Io.Clock.real.now(io).toSeconds();
var drained: [max_pending]Pending = undefined;
const batch = self.drain(io, &drained);
@@ -193,18 +209,21 @@ pub const Tracker = struct {
const due = self.passes % prune_every_passes == 0;
self.mutex.unlock(io);
if (!due) return;
const cutoff = std.Io.Clock.real.now(io).toSeconds() - @as(i64, self.retention_days) * 86_400;
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
self.mutex.lockUncancelable(io);
self.stats.pruned += deleted;
self.mutex.unlock(io);
} else |err| {
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
self.mutex.lockUncancelable(io);
self.stats.flush_failures += 1;
self.mutex.unlock(io);
if (due) {
const cutoff = now_s - @as(i64, self.retention_days) * 86_400;
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
self.mutex.lockUncancelable(io);
self.stats.pruned += deleted;
self.mutex.unlock(io);
} else |err| {
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
self.mutex.lockUncancelable(io);
self.stats.flush_failures += 1;
self.mutex.unlock(io);
}
}
if (names) |resolver| resolver.runPass(io, database, now_s);
}
pub fn snapshotStats(self: *Tracker, io: std.Io) Stats {
@@ -277,7 +296,7 @@ test "a client tracked twice before a flush yields one row at the later time" {
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));
@@ -305,7 +324,7 @@ test "distinct clients each get a row and ipv6 text is canonical" {
_ = tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
@@ -343,7 +362,7 @@ test "a full table drops further clients and counts them" {
// A tracked client still refreshes while the table is full, and the flush
// makes room for the next newcomer.
_ = tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
@@ -366,7 +385,7 @@ test "a flush touches a hand-edited row without changing what the operator set"
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
@@ -394,7 +413,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, monitor.writesAllowed());
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
@@ -403,7 +422,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
// Free space recovers and the same pending client lands.
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
tracker.flushOnce(io, &database, monitor.writesAllowed());
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.passes);
}
@@ -423,7 +442,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
const stats = tracker.snapshotStats(io);
@@ -433,7 +452,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
try database.exec("DROP TRIGGER refuse_insert;");
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
@@ -453,12 +472,12 @@ test "the pass that comes due prunes the clients that went quiet" {
var tracker: Tracker = .init(30);
// Every pass before the due one leaves both rows alone.
for (0..Tracker.prune_every_passes - 1) |_| {
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
}
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).pruned);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, now - 29 * day), try lastSeen(&database, "10.0.0.2"));
@@ -479,7 +498,7 @@ test "a shorter retention prunes what the default keeps" {
var tracker: Tracker = .init(1);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
@@ -501,6 +520,7 @@ test "the run loop flushes on its interval and returns on cancel" {
io,
&database,
@as(?*disk_monitor.Monitor, null),
@as(?*client_names.Resolver, null),
});
// The first flush is one interval away, so cancelling immediately proves the
// loop starts by sleeping rather than by writing.
@@ -508,3 +528,124 @@ test "the run loop flushes on its interval and returns on cancel" {
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}
// --- the naming step's place in the pass (milestone-25 ruling 1) ------------
const forward_zones = @import("../local/forward_zones.zig");
const local_tables = @import("local_tables.zig");
const validate = @import("../config/validate.zig");
/// Counts exchanges and times out on every one, and records how many client
/// rows existed when the first exchange was attempted.
const CountingExchange = struct {
var calls: usize = 0;
var database: ?*db.Db = null;
var rows_at_first_call: i64 = -1;
fn reset(target: *db.Db) void {
calls = 0;
database = target;
rows_at_first_call = -1;
}
fn exchange(
_: std.Io,
_: validate.Resolver,
_: []const u8,
_: []u8,
) @import("../upstream/transport.zig").ExchangeError![]u8 {
if (calls == 0) {
rows_at_first_call = clients_repo.countClients(database.?) catch -1;
}
calls += 1;
return error.Timeout;
}
};
fn namingTables(io: std.Io, tables: *local_tables.LocalTables) !void {
tables.swap(io, testing.allocator, .empty, try forward_zones.Zones.build(
testing.allocator,
&.{.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" }},
));
}
test "the drain lands before any exchange, and attempts stop at the cap" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
var tracker: Tracker = .init(30);
const pending = clients_repo.max_per_pass + 4;
for (0..pending) |i| {
_ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
}
tracker.flushOnce(io, &database, true, &names);
// Every pending row was written before the first exchange went out, which
// is what keeps a slow resolver off the drain.
try testing.expectEqual(@as(i64, @intCast(pending)), CountingExchange.rows_at_first_call);
try testing.expectEqual(clients_repo.max_per_pass, CountingExchange.calls);
try testing.expectEqual(@as(u64, @intCast(pending)), tracker.snapshotStats(io).flushed);
try testing.expectEqual(@as(u64, clients_repo.max_per_pass), names.snapshotStats(io).failed);
}
test "a row the due pass prunes is never asked about" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
const now = std.Io.Clock.real.now(io).toSeconds();
try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);
var tracker: Tracker = .init(30);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, &names);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}
test "a gated pass attempts no naming either" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
var tracker: Tracker = .init(30);
tracker.flushOnce(io, &database, false, &names);
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}
+1 -1
View File
@@ -844,7 +844,7 @@ test "S7 case 10: the querying client is materialised as a row" {
// Two queries from one client are one pending entry, and the forced pass
// stands in for the 60-second flush interval (S4 As-built seam).
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));