db-mode config changes apply live in-process
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s

settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
This commit is contained in:
2026-08-24 00:04:28 +02:00
parent f7f4c8be09
commit ce143d1d87
47 changed files with 7698 additions and 926 deletions
+274 -8
View File
@@ -109,10 +109,13 @@ pub const CertStore = struct {
pub const Kind = enum { doh, dot };
gpa: std.mem.Allocator,
/// Borrowed from the config; must outlive the store.
cert_path: []const u8,
/// Borrowed from the config; must outlive the store.
key_path: []const u8,
/// Owned. A settings apply replaces both paths, so a borrowed config slice
/// would dangle the moment the row it came from went away. Read and
/// written only under `reload_mutex`, which every apply and every reload
/// holds for its whole read-build-publish sequence.
cert_path: []u8,
/// Owned; see `cert_path`.
key_path: []u8,
/// Passed through to every `ServerContext.init`; the same lifetime rule
/// applies — a comptime-constant NULL-terminated array, never memory that
/// can go away before the store.
@@ -136,7 +139,8 @@ pub const CertStore = struct {
/// Test seam: runs inside `reload` between a successful `load` and the
/// publish, i.e. inside `reload_mutex`. Lets a test occupy the window
/// where an unserialized reload could be overtaken. Must not call
/// `reload` synchronously (that would self-deadlock on `reload_mutex`).
/// `reload`, `pollOnce` or `preparePathChange` synchronously — all four
/// take `reload_mutex`, which is not reentrant.
after_load_hook: ?ReloadHook,
/// Set by the composition root right after `init`, with `diagnostics`.
@@ -168,10 +172,17 @@ pub const CertStore = struct {
alpn: ?[*:null]const ?[*:0]const u8,
) ReloadError!CertStore {
const first = try load(gpa, io, cert_path, key_path, alpn);
errdefer {
first.entry.ctx.deinit(gpa);
gpa.destroy(first.entry);
}
const owned_cert = try gpa.dupe(u8, cert_path);
errdefer gpa.free(owned_cert);
const owned_key = try gpa.dupe(u8, key_path);
return .{
.gpa = gpa,
.cert_path = cert_path,
.key_path = key_path,
.cert_path = owned_cert,
.key_path = owned_key,
.alpn = alpn,
.mutex = .init,
.reload_mutex = .init,
@@ -193,6 +204,8 @@ pub const CertStore = struct {
std.debug.assert(current.refs == 0);
self.mutex.unlock(io);
self.destroyEntry(current);
self.gpa.free(self.cert_path);
self.gpa.free(self.key_path);
self.* = undefined;
}
@@ -226,7 +239,14 @@ pub const CertStore = struct {
pub fn reload(self: *CertStore, io: std.Io) ReloadError!void {
self.reload_mutex.lockUncancelable(io);
defer self.reload_mutex.unlock(io);
return self.reloadLocked(io);
}
/// `reload`'s body, for callers that already hold `reload_mutex` —
/// `pollOnce` does, because it must read `cert_path`/`key_path` under the
/// same lock an apply replaces them under. `std.Io.Mutex` is not
/// reentrant, so this exists rather than a recursive `reload` call.
fn reloadLocked(self: *CertStore, io: std.Io) ReloadError!void {
const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| {
_ = self.reload_failures.fetchAdd(1, .monotonic);
return err;
@@ -247,6 +267,93 @@ pub const CertStore = struct {
self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic);
}
/// A candidate certificate loaded from new paths, not yet published.
/// Holding one means holding `reload_mutex`: exactly one of
/// `publishPathChange` or `abortPathChange` must follow, and it releases
/// the lock.
pub const PreparedPaths = struct {
cert_path: []u8,
key_path: []u8,
loaded: Loaded,
/// Read at prepare so publish reads no clock: publish must touch
/// nothing outside memory it already owns.
loaded_at_unix: i64,
};
/// Prepare half of a `doh_server`/`dot_server` cert-path change: takes
/// `reload_mutex` and loads the certificate and key from the NEW paths.
/// Nothing is published, so a failure leaves the store exactly as it was —
/// the old certificate keeps serving and the caller writes no DB row. The
/// lock is released on failure and held on success, which is what makes
/// the whole apply serialized against `reload` and `pollOnce`.
pub fn preparePathChange(
self: *CertStore,
io: std.Io,
cert_path: []const u8,
key_path: []const u8,
) ReloadError!PreparedPaths {
self.reload_mutex.lockUncancelable(io);
errdefer self.reload_mutex.unlock(io);
const owned_cert = try self.gpa.dupe(u8, cert_path);
errdefer self.gpa.free(owned_cert);
const owned_key = try self.gpa.dupe(u8, key_path);
errdefer self.gpa.free(owned_key);
const next = load(self.gpa, io, cert_path, key_path, self.alpn) catch |err| {
_ = self.reload_failures.fetchAdd(1, .monotonic);
return err;
};
return .{
.cert_path = owned_cert,
.key_path = owned_key,
.loaded = next,
.loaded_at_unix = std.Io.Clock.real.now(io).toSeconds(),
};
}
/// Publish half: infallible and I/O-free. The paths and the generation
/// they were loaded from are installed together — the generation `mutex`
/// is taken only for that swap, inside `reload_mutex`, the same lock order
/// `reload` uses. Releases `reload_mutex`.
///
/// Connections that pinned the old generation finish on the old
/// certificate; the old entry is freed once its last reader releases.
pub fn publishPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
const old_cert_path = self.cert_path;
const old_key_path = self.key_path;
self.mutex.lockUncancelable(io);
const old = self.current;
self.cert_path = prepared.cert_path;
self.key_path = prepared.key_path;
self.current = prepared.loaded.entry;
self.loaded = prepared.loaded.sig;
old.retired = true;
const free_old = old.refs == 0;
self.mutex.unlock(io);
// The branch runs before the counter bump, never after: a `bool` still
// live across an atomic read-modify-write is the zig 0.16.0 Debug
// miscompile AGENTS.md documents.
if (free_old) self.destroyEntry(old);
self.gpa.free(old_cert_path);
self.gpa.free(old_key_path);
_ = self.reloads.fetchAdd(1, .monotonic);
self.last_reload_unix.store(prepared.loaded_at_unix, .monotonic);
self.reload_mutex.unlock(io);
}
/// Discards a prepared candidate — the commit that would have published it
/// failed, or a sibling owner's prepare did. Releases `reload_mutex`.
pub fn abortPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
self.gpa.free(prepared.cert_path);
self.gpa.free(prepared.key_path);
self.destroyEntry(prepared.loaded.entry);
self.reload_mutex.unlock(io);
}
/// Sleep first: `init` just loaded the files this poll would compare
/// against. `.boot` so a suspended box still sees the interval elapse.
pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void {
@@ -265,7 +372,15 @@ pub const CertStore = struct {
/// changed, and the old one keeps serving either way. A failed reload
/// warns and counts (`reload_failures`); the signature stays at the loaded
/// pair, so every subsequent poll retries until the files parse.
///
/// The whole pass runs under `reload_mutex`: the paths it stats are the
/// ones an apply replaces, and a poll that read a path outside the lock
/// could stat a freed slice or reload a pair that was never published
/// together.
pub fn pollOnce(self: *CertStore, io: std.Io, now_s: i64) void {
self.reload_mutex.lockUncancelable(io);
defer self.reload_mutex.unlock(io);
const cert_sig = statSig(io, self.cert_path) catch |err| {
log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path});
self.reportReload(io, now_s, "stat of the certificate failed", @errorName(err));
@@ -290,7 +405,7 @@ pub const CertStore = struct {
return;
}
if (self.reload(io)) {
if (self.reloadLocked(io)) {
log.info("certificate reloaded from {s}", .{self.cert_path});
if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind));
} else |err| {
@@ -993,3 +1108,154 @@ test "an unchanged poll closes the episode a transient stat failure opened" {
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
}
// ---------------------------------------------------------------------------
// path apply (milestone-34 S3.4)
// ---------------------------------------------------------------------------
test "a bad candidate is refused at prepare and the store keeps serving" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
const before = store.acquire(io);
store.release(io, before);
const before_paths_cert = store.cert_path;
try env.tmp.dir.writeFile(io, .{ .sub_path = "bad.pem", .data = "not a certificate" });
var bad_buf: [128]u8 = undefined;
const bad_path = try std.fmt.bufPrint(&bad_buf, ".zig-cache/tmp/{s}/bad.pem", .{env.tmp.sub_path});
try testing.expectError(
error.CertParse,
store.preparePathChange(io, bad_path, env.key_path),
);
// Nothing published: same generation, same paths, and `reload_mutex` was
// released — a second prepare would deadlock otherwise.
const after = store.acquire(io);
store.release(io, after);
try testing.expectEqual(before, after);
try testing.expectEqual(before_paths_cert.ptr, store.cert_path.ptr);
try testing.expectEqualStrings(env.cert_path, store.cert_path);
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
// A missing candidate path is refused the same way.
try testing.expectError(
error.CertUnreadable,
store.preparePathChange(io, "./nxdns-no-such-cert-4a11.pem", env.key_path),
);
}
test "a published path change installs the new pair and retires the old generation" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
// A second, byte-different copy of the same valid pair under new names.
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
defer testing.allocator.free(grown);
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
// A connection pinned to the old generation finishes on it.
const pinned = store.acquire(io);
const prepared = try store.preparePathChange(io, next_cert, next_key);
store.publishPathChange(io, prepared);
try testing.expectEqualStrings(next_cert, store.cert_path);
try testing.expectEqualStrings(next_key, store.key_path);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
try testing.expect(pinned.retired);
const serving = store.acquire(io);
try testing.expect(serving != pinned);
store.release(io, serving);
store.release(io, pinned);
// The watcher now measures the new pair, so an untouched pair polls clean
// and a rewritten one reloads.
store.pollOnce(io, 1_000);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = fixtures.cert_pem });
store.pollOnce(io, 1_100);
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
}
test "an aborted path change frees the candidate and leaves the store untouched" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
const before = store.acquire(io);
store.release(io, before);
// The commit this candidate was built for failed; the testing allocator
// proves the abort frees everything the prepare took.
const prepared = try store.preparePathChange(io, env.cert_path, env.key_path);
store.abortPathChange(io, prepared);
const after = store.acquire(io);
store.release(io, after);
try testing.expectEqual(before, after);
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
// `reload_mutex` came back, so the store still reloads.
try store.reload(io);
}
test "a reload racing a path apply is serialized behind it" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
defer testing.allocator.free(grown);
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
// Prepare holds `reload_mutex` across the whole apply.
const prepared = try store.preparePathChange(io, next_cert, next_key);
try testing.expect(!store.reload_mutex.tryLock());
// The concurrent reload cannot start, so it cannot publish the OLD paths
// over the new generation.
var racing = try io.concurrent(CertStore.reload, .{ &store, io });
store.publishPathChange(io, prepared);
try racing.await(io);
// Two publications, and the last word is the apply's pair: the racing
// reload reread the paths the apply installed.
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
try testing.expectEqualStrings(next_cert, store.cert_path);
store.mutex.lockUncancelable(io);
const final = store.loaded;
store.mutex.unlock(io);
try testing.expectEqual(@as(u64, grown.len), final.cert.size);
}