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
+556
View File
@@ -252,6 +252,200 @@ fn installWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) void {
if (state.output == .file) openFileLocked();
}
// ---------------------------------------------------------------------------
// Hot apply (milestone-34 S3.5)
// ---------------------------------------------------------------------------
/// Which of the three disjoint shapes a `logging` apply takes. The case is
/// decided from the FINAL MERGED config against the live sink, and it depends
/// only on `output` and `file_path` — fields nothing but an apply writes.
/// Rotation and write-failure recovery move `file`, `file_pos` and
/// `rotate_pending`, never these two, so a case decided in one lock hold is
/// still the right case in the next.
pub const ApplyCase = enum {
/// The merged config wants a file, and it is not the file that is open:
/// the path differs, or output is switching TO file.
target_changed,
/// Output is switching away from file. Nothing to open.
target_removed,
/// Everything else — output stays stderr/syslog, or output stays file on
/// the SAME path. Only config fields move; the handle and its position and
/// rotation state stay with the rotation machinery that owns them.
target_unchanged,
};
/// The complete new target state for a `target_changed` apply. The handle
/// couples to both other fields: inheriting the old `file_pos` would write
/// past the new file's end, and inheriting a pending rotation would rotate the
/// new target on its first line.
pub const PreparedSink = struct {
file: std.Io.File,
file_pos: u64,
rotate_pending: bool = false,
};
pub const PrepareError = error{
/// `file_path` does not fit in the sink's path buffer, so the sink could
/// not name the file it was told to write.
PathTooLong,
/// The new target could not be opened, created, or measured.
TargetUnopenable,
};
/// A validated `logging` apply, owning everything publish needs. Publish takes
/// no borrow from the request arena, so this outlives the request that built
/// it.
pub const PreparedApply = struct {
case: ApplyCase,
threshold: std.log.Level,
output: model.LogOutput,
path_buf: [std.Io.Dir.max_path_bytes]u8,
path_len: usize,
max_files: u8,
max_bytes: u64,
sink: ?PreparedSink,
pub fn path(self: *const PreparedApply) []const u8 {
return self.path_buf[0..self.path_len];
}
};
/// Prepare: everything fallible happens here, and nothing is published. A
/// `target_changed` apply opens the NEW file and MEASURES it — the open is the
/// step that can fail, and doing it here closes the close-then-reopen window
/// `installWithMaxBytes` has, where a bad new path leaves no sink at all.
pub fn prepareApply(io: std.Io, cfg: model.Logging) PrepareError!PreparedApply {
return prepareApplyWithMaxBytes(io, cfg, model.maxLogBytes(cfg));
}
/// Test-only entry point, mirroring `installForTest`.
pub fn prepareApplyForTest(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
return prepareApplyWithMaxBytes(io, cfg, max_bytes);
}
fn prepareApplyWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
if (cfg.file_path.len > std.Io.Dir.max_path_bytes) return error.PathTooLong;
var prepared: PreparedApply = .{
.case = undefined,
.threshold = toStdLevel(cfg.level),
.output = cfg.output,
.path_buf = undefined,
.path_len = cfg.file_path.len,
.max_files = cfg.max_files,
.max_bytes = max_bytes,
.sink = null,
};
@memcpy(prepared.path_buf[0..prepared.path_len], cfg.file_path);
prepared.case = classifyApply(cfg);
if (prepared.case == .target_changed) {
prepared.sink = try openTarget(io, prepared.path());
}
return prepared;
}
fn classifyApply(cfg: model.Logging) ApplyCase {
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
const currently_file = state.output == .file;
if (cfg.output != .file) return if (currently_file) .target_removed else .target_unchanged;
if (!currently_file) return .target_changed;
return if (std.mem.eql(u8, state.path(), cfg.file_path)) .target_unchanged else .target_changed;
}
/// Opens `p` and measures it, without touching the live sink.
fn openTarget(io: std.Io, p: []const u8) PrepareError!PreparedSink {
if (p.len == 0) return error.TargetUnopenable;
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
const dir: std.Io.Dir = .cwd();
const file = dir.openFile(io, p, .{ .mode = .write_only }) catch |open_err| switch (open_err) {
error.FileNotFound => dir.createFile(io, p, .{ .truncate = false }) catch
return error.TargetUnopenable,
else => return error.TargetUnopenable,
};
errdefer file.close(io);
// A pre-existing nonempty target is appended to, so the new position is
// its measured length rather than zero.
const length = file.length(io) catch return error.TargetUnopenable;
return .{ .file = file, .file_pos = length, .rotate_pending = false };
}
/// Publish: infallible and I/O-free, one hold of the sink lock. Returns the
/// DETACHED old handle, which `retireApply` closes — closing a file is retire
/// work, and doing it here would put a syscall inside the publish.
pub fn publishApply(prepared: PreparedApply) ?std.Io.File {
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
state.threshold = prepared.threshold;
state.output = prepared.output;
state.path_len = prepared.path_len;
@memcpy(state.path_buf[0..state.path_len], prepared.path());
state.max_files = prepared.max_files;
state.max_bytes = prepared.max_bytes;
switch (prepared.case) {
// The handle and its position and rotation state are not this apply's
// to move; a broken handle is repaired by the existing per-write
// recovery, not by a config change.
.target_unchanged => return null,
.target_changed => {
const detached = state.file;
const sink = prepared.sink.?;
state.file = sink.file;
state.file_pos = sink.file_pos;
state.rotate_pending = sink.rotate_pending;
return detached;
},
.target_removed => {
const detached = state.file;
state.file = null;
state.file_pos = 0;
state.rotate_pending = false;
return detached;
},
}
}
/// Retire: closes the handle `publishApply` detached, after no writer can
/// reach it — the swap happened under the sink lock, so any writer that held
/// it has already returned.
pub fn retireApply(io: std.Io, detached: ?std.Io.File) void {
const file = detached orelse return;
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
file.close(io);
}
/// Discards a prepared apply that will not be published, because its commit
/// failed or a sibling owner's prepare did.
pub fn abortApply(io: std.Io, prepared: PreparedApply) void {
const sink = prepared.sink orelse return;
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
sink.file.close(io);
}
/// The directory the disk monitor should measure for `cfg`: the log file's
/// directory when output is `file`, and null otherwise — with output on
/// stderr or syslog there is no log file to run out of room for.
///
/// A path with no directory component measures the working directory, which is
/// where a bare filename lands.
pub fn logDirname(cfg: model.Logging) ?[]const u8 {
if (cfg.output != .file) return null;
if (cfg.file_path.len == 0) return null;
return std.fs.path.dirname(cfg.file_path) orelse ".";
}
/// Flushes and closes the file, and restores pass-through stderr formatting.
pub fn deinstall() void {
var stderr_buf: [64]u8 = undefined;
@@ -1099,3 +1293,365 @@ test "a message over the buffer is marked and counted" {
const colon = std.mem.indexOf(u8, written, ": ").?;
try testing.expectEqual(max_message_bytes, written[colon + 2 ..].len - 1);
}
// ---------------------------------------------------------------------------
// hot apply (milestone-34 S3.5)
// ---------------------------------------------------------------------------
/// The sink is one process-wide `state` behind the stderr lock, so an apply
/// test has to save it, drive the apply, and put it back. Nothing here holds
/// the lock across an apply call: `classifyApply` and `publishApply` take it
/// themselves.
const ApplyFixture = struct {
threaded: std.Io.Threaded,
tmp: testing.TmpDir,
saved: State,
fn init(self: *ApplyFixture) void {
self.threaded = .init(testing.allocator, .{});
self.tmp = testing.tmpDir(.{});
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
self.saved = state;
state = .{};
state.io = self.threaded.io();
}
fn deinit(self: *ApplyFixture) void {
{
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
if (state.file) |f| f.close(self.threaded.io());
state = self.saved;
}
self.tmp.cleanup();
self.threaded.deinit();
}
fn io(self: *ApplyFixture) std.Io {
return self.threaded.io();
}
fn path(self: *ApplyFixture, buf: []u8, name: []const u8) []const u8 {
return std.fmt.bufPrint(buf, ".zig-cache/tmp/{s}/{s}", .{ self.tmp.sub_path, name }) catch unreachable;
}
/// Puts the sink on `p` with a real open handle, the way a running server
/// with `output = file` sits.
fn openOn(self: *ApplyFixture, p: []const u8) void {
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
state.installed = true;
state.output = .file;
state.path_len = p.len;
@memcpy(state.path_buf[0..p.len], p);
state.max_bytes = 1 << 20;
state.max_files = 3;
openFileLocked();
_ = self;
}
fn snapshot(self: *ApplyFixture) State {
_ = self;
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
return state;
}
fn setHandleState(self: *ApplyFixture, file: ?std.Io.File, file_pos: u64, rotate_pending: bool) void {
_ = self;
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
state.file = file;
state.file_pos = file_pos;
state.rotate_pending = rotate_pending;
}
/// Writes one record through the live handle, as `emitFileLocked` does.
fn writeThroughSink(self: *ApplyFixture, text: []const u8) !void {
_ = self;
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
try writeLineLocked(state.file.?, text);
}
fn read(self: *ApplyFixture, buf: []u8, name: []const u8) ![]u8 {
return self.tmp.dir.readFileAlloc(self.io(), name, testing.allocator, .limited(buf.len)) catch |err| return err;
}
};
fn fileCfg(p: []const u8) model.Logging {
return .{ .output = .file, .file_path = p, .level = .info, .max_files = 3, .max_size_mb = 1 };
}
test "a bad target path is refused at prepare and the live sink is untouched" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const live = fx.path(&buf, "nxdns.log");
fx.openOn(live);
const before = fx.snapshot();
try testing.expect(before.file != null);
// A directory that does not exist: the open and the create both fail.
var bad_buf: [200]u8 = undefined;
const bad = fx.path(&bad_buf, "no-such-dir/nxdns.log");
try testing.expectError(
error.TargetUnopenable,
prepareApplyForTest(fx.io(), fileCfg(bad), 1 << 20),
);
const after = fx.snapshot();
try testing.expectEqual(before.file.?.handle, after.file.?.handle);
try testing.expectEqualStrings(live, after.path());
}
test "a target change publishes without closing, and retire closes the old handle" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var first_buf: [160]u8 = undefined;
var second_buf: [160]u8 = undefined;
const first = fx.path(&first_buf, "first.log");
const second = fx.path(&second_buf, "second.log");
fx.openOn(first);
const before = fx.snapshot();
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
try testing.expectEqual(ApplyCase.target_changed, prepared.case);
const detached = publishApply(prepared);
const after = fx.snapshot();
// Publish swapped the handle and left the old one OPEN: the old descriptor
// still writes, which it could not if publish had closed it.
try testing.expectEqual(before.file.?.handle, detached.?.handle);
try testing.expect(after.file.?.handle != detached.?.handle);
try testing.expectEqualStrings(second, after.path());
try testing.expectEqual(@as(u64, 0), after.file_pos);
try testing.expect(!after.rotate_pending);
var old_writer_buf: [64]u8 = undefined;
var ow = detached.?.writer(fx.io(), &old_writer_buf);
try ow.interface.writeAll("still open\n");
try ow.interface.flush();
retireApply(fx.io(), detached);
// The new target receives lines.
try fx.writeThroughSink("1 info: on the new target\n");
var read_buf: [256]u8 = undefined;
const contents = try fx.read(&read_buf, "second.log");
defer testing.allocator.free(contents);
try testing.expectEqualStrings("1 info: on the new target\n", contents);
}
test "switching to a pre-existing nonempty file starts at its measured length" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
const existing = "already here\n";
try fx.tmp.dir.writeFile(fx.io(), .{ .sub_path = "kept.log", .data = existing });
var first_buf: [160]u8 = undefined;
var kept_buf: [160]u8 = undefined;
fx.openOn(fx.path(&first_buf, "first.log"));
const kept = fx.path(&kept_buf, "kept.log");
const prepared = try prepareApplyForTest(fx.io(), fileCfg(kept), 1 << 20);
try testing.expectEqual(@as(u64, existing.len), prepared.sink.?.file_pos);
retireApply(fx.io(), publishApply(prepared));
try testing.expectEqual(@as(u64, existing.len), fx.snapshot().file_pos);
// Inheriting the old position would have overwritten the existing bytes.
try fx.writeThroughSink("appended\n");
var read_buf: [256]u8 = undefined;
const contents = try fx.read(&read_buf, "kept.log");
defer testing.allocator.free(contents);
try testing.expectEqualStrings(existing ++ "appended\n", contents);
}
test "a target change does not inherit a pending rotation" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var first_buf: [160]u8 = undefined;
var second_buf: [160]u8 = undefined;
fx.openOn(fx.path(&first_buf, "first.log"));
const second = fx.path(&second_buf, "second.log");
// A rotation the old target owed and never completed: the handle is closed
// and the rotation is still pending.
const stale = fx.snapshot().file.?;
stale.close(fx.io());
fx.setHandleState(null, 0, true);
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
try testing.expect(!prepared.sink.?.rotate_pending);
retireApply(fx.io(), publishApply(prepared));
const after = fx.snapshot();
try testing.expect(!after.rotate_pending);
try testing.expect(after.file != null);
try testing.expectEqual(@as(u64, 0), after.file_pos);
}
test "a file to stderr apply detaches the handle and clears position and rotation" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const live = fx.path(&buf, "nxdns.log");
fx.openOn(live);
fx.setHandleState(fx.snapshot().file, 4_096, true);
const before = fx.snapshot();
const prepared = try prepareApplyForTest(fx.io(), .{
.output = .stderr,
.file_path = live,
.level = .warn,
}, 1 << 20);
try testing.expectEqual(ApplyCase.target_removed, prepared.case);
const detached = publishApply(prepared);
const after = fx.snapshot();
try testing.expectEqual(before.file.?.handle, detached.?.handle);
try testing.expectEqual(@as(?std.Io.File, null), after.file);
try testing.expectEqual(@as(u64, 0), after.file_pos);
try testing.expect(!after.rotate_pending);
try testing.expectEqual(model.LogOutput.stderr, after.output);
// The path still moves, so a later switch back to file opens the right one.
try testing.expectEqualStrings(live, after.path());
retireApply(fx.io(), detached);
}
test "a same-path apply changes only config fields, whatever the handle is doing" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const live = fx.path(&buf, "nxdns.log");
fx.openOn(live);
// Publish takes the same lock a rotation and a write-failure closure hold,
// so the only reachable interleavings are "before publish" and "after".
// Both leave the handle state the apply must not touch; these are the two
// states each of them leaves behind.
const handle_states = [_]struct { file: bool, pos: u64, pending: bool }{
// Mid-rotation: handle closed, rotation owed.
.{ .file = false, .pos = 0, .pending = true },
// Healthy and part-written.
.{ .file = true, .pos = 8_192, .pending = false },
};
const open_handle = fx.snapshot().file.?;
for (handle_states) |want| {
fx.setHandleState(if (want.file) open_handle else null, want.pos, want.pending);
const prepared = try prepareApplyForTest(fx.io(), .{
.output = .file,
.file_path = live,
.level = .debug,
.max_files = 9,
.max_size_mb = 7,
}, 4_242);
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
// Nothing detached, so retire has nothing to close.
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
const after = fx.snapshot();
try testing.expectEqual(want.pos, after.file_pos);
try testing.expectEqual(want.pending, after.rotate_pending);
try testing.expectEqual(want.file, after.file != null);
// The config fields did move.
try testing.expectEqual(std.log.Level.debug, after.threshold);
try testing.expectEqual(@as(u8, 9), after.max_files);
try testing.expectEqual(@as(u64, 4_242), after.max_bytes);
}
fx.setHandleState(open_handle, 0, false);
}
test "a file_path change while output is stderr updates the config and touches no handle" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const later = fx.path(&buf, "later.log");
const prepared = try prepareApplyForTest(fx.io(), .{
.output = .syslog,
.file_path = later,
.level = .info,
}, 1 << 20);
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
const after = fx.snapshot();
try testing.expectEqual(@as(?std.Io.File, null), after.file);
try testing.expectEqualStrings(later, after.path());
// The later switch to file opens exactly that path.
const to_file = try prepareApplyForTest(fx.io(), fileCfg(later), 1 << 20);
try testing.expectEqual(ApplyCase.target_changed, to_file.case);
retireApply(fx.io(), publishApply(to_file));
try testing.expect(fx.snapshot().file != null);
}
test "an aborted apply closes the target it opened" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const target = fx.path(&buf, "never.log");
// The commit failed, so the prepared target must not leak its descriptor.
const prepared = try prepareApplyForTest(fx.io(), fileCfg(target), 1 << 20);
abortApply(fx.io(), prepared);
const after = fx.snapshot();
try testing.expectEqual(@as(?std.Io.File, null), after.file);
try testing.expectEqual(model.LogOutput.stderr, after.output);
}
test "logDirname follows output and file_path in both directions" {
try testing.expectEqualStrings("/var/log/nxdns", logDirname(.{
.output = .file,
.file_path = "/var/log/nxdns/nxdns.log",
}).?);
// A bare filename lands in the working directory.
try testing.expectEqualStrings(".", logDirname(.{
.output = .file,
.file_path = "nxdns.log",
}).?);
// Output away from file stops the measurement whatever the path says.
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
.output = .stderr,
.file_path = "/var/log/nxdns/nxdns.log",
}));
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
.output = .syslog,
.file_path = "/var/log/nxdns/nxdns.log",
}));
}