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
+90 -29
View File
@@ -1,11 +1,13 @@
//! `/api/upstreams` — the resolvers nxdns forwards to.
//!
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
//! mutable resource that is NOT live. The pool builds its clients, its health
//! state and its TLS material at startup, so an upstream added, edited or
//! removed here takes effect at the next restart. The response says so through
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
//! has one banner and one meaning for it.
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
//! live like the rest of them: an upstream added, edited or removed here is
//! applied in-process. The row set the write will leave behind is built into a
//! candidate generation BEFORE the write, so a set that cannot produce clients
//! is refused with nothing changed; the candidate is published after the commit
//! and the displaced generation retires once the exchanges holding it finish.
//! `restart_required` in the response is therefore `false`, and stays in the
//! shape so the UI has one field with one meaning across every mutation.
//!
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
//! empty for every other scheme, and the validator refuses it there.
@@ -13,6 +15,8 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const apply = @import("apply.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -38,6 +42,34 @@ const Created = union(enum) { id: i64, fail: Failure };
// decisions
// ---------------------------------------------------------------------------
/// Prepares the generation the row set after `mutation` would produce, on a
/// caller that already holds `config_lock`. Returns the plan for the caller to
/// publish or abandon.
fn planFor(
state: *server.WebState,
io: std.Io,
arena: Allocator,
database: *db.Db,
mutation: apply.RowMutation,
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
const servers = try apply.hypotheticalRows(arena, rows.items, mutation);
var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
if (try plan.prepareUpstreams(io, servers)) |failure| {
plan.abandon(io);
return .{ .fail = failure };
}
return .{ .plan = plan };
}
fn applyCreate(
state: *server.WebState,
io: std.Io,
@@ -48,14 +80,20 @@ fn applyCreate(
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
const inserted = upstreams_repo.insertUpstreamRow(database, item);
state.config_lock.unlock(io);
defer state.config_lock.unlock(io);
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
// Ruling 12: the pool is built at startup, so the row now stored governs
// nothing until the next one. Stored after the insert, never before — a
// rejected url or a conflict owes no restart.
state.restart_pending.store(true, .monotonic);
var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
.fail => |failure| return .{ .fail = failure },
.plan => |p| p,
};
const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
plan.abandon(io);
return .{ .fail = mutations.dbFailure(err, url_conflict) };
};
plan.publish(io);
plan.retire(io);
return .{ .id = id };
}
@@ -85,16 +123,27 @@ fn applyUpdate(
}
}
upstreams_repo.updateUpstream(database, id, item) catch |err|
var plan = switch (try planFor(state, io, arena, database, .{
.replace = .{ .id = id, .item = item },
})) {
.fail => |failure| return failure,
.plan => |p| p,
};
upstreams_repo.updateUpstream(database, id, item) catch |err| {
plan.abandon(io);
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
};
plan.publish(io);
plan.retire(io);
return null;
}
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -109,19 +158,28 @@ fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?
},
}
upstreams_repo.deleteUpstream(database, id) catch |err|
var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
.fail => |failure| return failure,
.plan => |p| p,
};
upstreams_repo.deleteUpstream(database, id) catch |err| {
plan.abandon(io);
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
};
plan.publish(io);
plan.retire(io);
return null;
}
const Remaining = union(enum) { missing, count: usize };
fn countEnabledExcept(
database: *@import("../../storage/db.zig").Db,
database: *db.Db,
arena: Allocator,
id: i64,
) @import("../../storage/db.zig").Error!Remaining {
) db.Error!Remaining {
const rows = try upstreams_repo.listUpstreamRows(database, arena);
var found = false;
var left: usize = 0;
@@ -166,7 +224,7 @@ pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
.restart_required = false,
}, &.{}),
};
}
@@ -186,7 +244,7 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
.restart_required = false,
}, &.{});
}
@@ -219,7 +277,7 @@ test "a created upstream is stored" {
try testing.expectEqualStrings("", row.tls_name);
}
test "an upstream change never announces a reload" {
test "an upstream change never announces a reload or a restart" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
@@ -251,7 +309,7 @@ test "a url the validator refuses never reaches the database" {
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
}
test "a refused upstream write owes no restart, and an accepted one does" {
test "no upstream write owes a restart, refused or accepted" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
@@ -264,11 +322,14 @@ test "a refused upstream write owes no restart, and an accepted one does" {
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
try testing.expect(applyDelete(&bench.state, bench.io(), bench.arena(), 999).? == .not_found);
try testing.expect((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
// Milestone 34: an accepted write is applied in-process, so it owes no
// restart either. `restart_pending` now has exactly two sources, and
// neither of them is here.
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
try testing.expect(bench.state.restart_pending.load(.monotonic));
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "a duplicate url is a conflict" {
@@ -287,7 +348,7 @@ test "the last enabled upstream cannot be deleted" {
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
const failure = try applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
@@ -296,7 +357,7 @@ test "the last enabled upstream cannot be deleted" {
});
try testing.expectEqual(
@as(?Failure, null),
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
try applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
);
try testing.expectEqual(
@as(i64, 1),
@@ -357,6 +418,6 @@ test "an id no upstream holds is a 404 on both update and delete" {
);
try testing.expectEqual(
Failure.not_found,
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
(try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
);
}