db-mode config changes apply live in-process

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 17e6e93ce1
commit d961b152a3
47 changed files with 7698 additions and 926 deletions
+90 -104
View File
@@ -1,11 +1,14 @@
//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
//! `settings` table (ruling 16).
//!
//! Everything here is restart-required this milestone, and the response says so
//! for every key: what changes live is the resource endpoints and the pause,
//! not a setting. The list is generated from `model.Config` itself, so a
//! section added to the model appears here without anyone remembering to add
//! it.
//! Every key applies live except the two that create or destroy a socket —
//! `bind` (a listener's address, port or existence) and `web_lifecycle`
//! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute.
//! `restart_required` in the response is that pair of key groups and nothing
//! else. `apply.zig` holds the table that decides which owner a key belongs to
//! and drives the prepare → commit → publish → retire the write contract asks
//! for; a section added to the model fails to compile until its keys have
//! owners there.
//!
//! `web.password` is write-only and `web.password_hash` is neither readable nor
//! directly writable. A PUT carrying `web.password` hashes it with the import
@@ -23,6 +26,7 @@ const builtin = @import("builtin");
const std = @import("std");
const Allocator = std.mem.Allocator;
const apply = @import("apply.zig");
const auth = @import("../auth.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
@@ -42,58 +46,13 @@ const log = std.log.scoped(.web_api);
/// always fits the copy `applyLogin` takes.
const hash_buf_len = auth.LiveHash.max_len;
/// Fields a client may neither read nor write directly. `password_hash` is
/// derived from `password`; exposing it would let a client install a hash
/// nxdns never computed.
fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
}
const isHidden = apply.isHidden;
const isWriteOnly = apply.isWriteOnly;
const isScalarSection = apply.isScalarSection;
/// `web.password` is accepted on a PUT and never returned.
fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
// ---------------------------------------------------------------------------
// the restart-required table (ruling 16)
// ---------------------------------------------------------------------------
/// Every settings key, in `model.Config` declaration order. Ruling 16: all of
/// them are restart-required this milestone, so the table is the key list and
/// the flag is implied by membership.
pub const restart_required_keys: []const []const u8 = &keys;
const keys = blk: {
var list: [countKeys()][]const u8 = undefined;
var index = 0;
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (!isScalarSection(section_field.type)) continue;
for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (isHidden(section_field.name, field.name)) continue;
if (isWriteOnly(section_field.name, field.name)) continue;
list[index] = section_field.name ++ "." ++ field.name;
index += 1;
}
}
break :blk list;
};
fn countKeys() usize {
var count = 0;
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (!isScalarSection(section_field.type)) continue;
for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (isHidden(section_field.name, field.name)) continue;
if (isWriteOnly(section_field.name, field.name)) continue;
count += 1;
}
}
return count;
}
/// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the
/// two operations milestone 35 executes. The apply table is the authority.
pub const restart_required_keys = apply.restart_required_keys;
// ---------------------------------------------------------------------------
// the patch a PUT carries
@@ -184,32 +143,6 @@ fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
return true;
}
/// Whether the patch names a key whose change waits for a restart. The key
/// table above is the authority, so a key that stops being restart-required
/// stops raising the flag without anyone remembering this function exists.
/// A patch carrying `web.password` alone touches no such key: the new hash is
/// installed live (ruling 17 of milestone 16).
fn touchesRestartRequiredKey(patch: Patch) bool {
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
if (@field(patch, section_field.name)) |section| {
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
if (@field(section, field.name) != null) {
if (comptime isRestartRequired(section_field.name ++ "." ++ field.name)) return true;
}
}
}
}
return false;
}
fn isRestartRequired(comptime key: []const u8) bool {
@setEvalBranchQuota(10_000);
for (keys) |listed| {
if (std.mem.eql(u8, listed, key)) return true;
}
return false;
}
/// Whether the patch carries a new password.
fn newPassword(patch: Patch) ?[]const u8 {
const web = patch.web orelse return null;
@@ -339,10 +272,11 @@ fn applyPut(
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
const stored = mutations.loadConfig(arena, database) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
var cfg = stored;
var bad_key: []const u8 = "";
if (!merge(&cfg, patch, &bad_key)) {
@@ -366,15 +300,38 @@ fn applyPut(
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
errdefer if (replacement) |hash| state.gpa.free(hash);
// Prepare: one candidate per owner whose value actually moved, built from
// the merged configuration. Nothing is published and no row is written
// until every one of them succeeded.
const ops = apply.changedOperations(stored, cfg);
var plan: apply.Plan = .init(state, arena, cfg, ops);
if (try plan.prepare(io)) |failure| {
plan.abandon(io);
if (replacement) |hash| state.gpa.free(hash);
return .{ .fail = failure };
}
writeSettings(arena, database, cfg) catch |err| {
plan.abandon(io);
if (replacement) |hash| state.gpa.free(hash);
return .{ .fail = .{ .internal = err } };
};
plan.publish(io);
plan.retire(io);
// After the commit, never before: a validation failure or a write that
// rolled back changed nothing, so it owes nobody a restart.
if (touchesRestartRequiredKey(patch)) state.restart_pending.store(true, .monotonic);
// rolled back changed nothing, so it owes nobody a restart. Only the two
// operations milestone 35 executes raise it; everything else is already
// live by the time this line runs.
var restart_owed = false;
var it = ops.iterator();
while (it.next()) |operation| {
if (operation.needsRestart()) restart_owed = true;
}
if (restart_owed) state.restart_pending.store(true, .monotonic);
if (replacement) |hash| {
// Ruling 17, both halves: the running server must verify against the
@@ -546,11 +503,10 @@ fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config
const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
test "the restart-required list is a strict subset of the settings keys" {
// `model.toSettings` is the key list this form writes. The restart list is
// now the small part of it that milestone 35 owns, so it must be shorter
// than the key list and every entry must still be a real key.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -558,17 +514,24 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
try testing.expect(restart_required_keys.len < pairs.items.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
var is_a_setting = false;
for (pairs.items) |pair| {
if (std.mem.eql(u8, pair.key, key)) is_a_setting = true;
}
try testing.expect(is_a_setting);
}
var found_port = false;
var found_ttl = false;
for (restart_required_keys) |key| {
if (std.mem.eql(u8, key, "dns.port")) found_port = true;
if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true;
}
try testing.expect(found_port);
// A live key must not appear: the UI renders no restart affordance for it.
try testing.expect(!found_ttl);
}
test "the read shape spells every enum the way the database does" {
@@ -806,20 +769,43 @@ test "a rejected patch raises no restart flag" {
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "only a patch naming a restart-required key raises the flag" {
var password_only: Patch = .{};
password_only.web = .{ .password = "correct horse battery staple" };
try testing.expect(!touchesRestartRequiredKey(password_only));
test "only a bind or web-lifecycle change raises the flag" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
var mixed: Patch = .{};
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
try testing.expect(touchesRestartRequiredKey(mixed));
// A live key, however many of them: nothing to restart for.
var live: Patch = .{};
live.blocking = .{ .ttl = 30 };
live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
var elsewhere: Patch = .{};
elsewhere.dns = .{ .port = 5353 };
try testing.expect(touchesRestartRequiredKey(elsewhere));
// `web.enabled` is the second of the two milestone-35 operations, and it
// executes nothing: the row moves and the flag rises.
var lifecycle: Patch = .{};
lifecycle.web = .{ .enabled = false };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config);
try testing.expect(bench.state.restart_pending.load(.monotonic));
try testing.expectEqual(
@as(i64, 1),
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"),
);
}
try testing.expect(!touchesRestartRequiredKey(.{}));
test "a patch that changes nothing applies nothing" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
// The stored port is 53, and the admin form submits every field it read.
// Rewriting a value to itself is not a change and must not raise the flag.
var same: Patch = .{};
same.dns = .{ .port = 53 };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "a password-only put leaves the restart flag alone" {