query log batching: one transaction per flush interval, not per query
Gates / frontend (push) Successful in 1m18s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 7m33s
Gates / package (push) Successful in 5m34s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 16m16s
Gates / frontend (push) Successful in 1m8s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 9m15s
Release / guard (push) Successful in 19s
Gates / test (push) Successful in 1m34s
Gates / test-aarch64 (push) Successful in 6m46s
Gates / package (push) Successful in 39s
Release / publish (push) Failing after 4m7s

This commit is contained in:
2026-08-20 20:57:11 +02:00
parent 037f209179
commit addf24f92c
18 changed files with 422 additions and 61 deletions
+26 -19
View File
@@ -921,11 +921,31 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// while a task could still touch it.
var group: std.Io.Group = .init;
// The gate every non-essential write consults. Reading it before the
// monitor's own task has sampled is safe: a fresh `Monitor` publishes `.ok`
// (disk_monitor.zig:63), so nothing is refused for want of a sample.
const gate: ?*disk_monitor.Monitor = &monitor;
// The query-log writer is deliberately *not* in `group`, and starts before
// every producer. Inside the group its life would end with the same
// `cancel` that stops the producers, and cancellation would race the
// queue's close: whichever landed first decided whether the batch the
// writer was holding reached the database or was counted as dropped. Given
// its own future, it outlives the producers by construction, and the
// teardown below can close the queue with nobody left to fill it and then
// wait for the writer to finish emptying it.
var writer_future = try io.concurrent(
logger_mod.Logger.runWriter,
.{ &query_logger, io, &querylog_writer_db, gate },
);
// Ruling 4's shutdown order, on the one path every exit from here takes:
// the logger sees a closed queue and drains what it holds rather than
// losing it to cancellation (ruling 22), then every task stops, and only
// then does the final flush run with no recording task left that could
// add a cell after it.
// every producer stops and is joined, then the queue closes, then the
// writer is awaited — so the last batch is written rather than raced — and
// only then does the final history flush run, with no recording task left
// that could add a cell after it. A writer the disk gate will not let write
// counts its batch as dropped instead of holding the exit open
// (`logger.zig`), so this wait always ends.
//
// A `defer` and not straight-line code after `shutdown.wait`, because a
// `concurrent` spawn below can fail with the DNS listeners already
@@ -933,25 +953,12 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// signal gets. The `querylog_history_db` this flush writes through is
// declared above, so its `close` runs after it.
defer {
query_logger.shutdown(io);
group.cancel(io);
query_logger.shutdown(io);
writer_future.await(io) catch {};
history.flushOnce(io, &querylog_history_db, upstream_history_repo.flush);
}
// The gate every non-essential write consults. Reading it before the
// monitor's own task has sampled is safe: a fresh `Monitor` publishes `.ok`
// (disk_monitor.zig:63), so nothing is refused for want of a sample.
const gate: ?*disk_monitor.Monitor = &monitor;
// The writer starts before the listeners, and that order is the deferred
// drain's precondition: a listener that is already accepting queries
// enqueues log entries, and `Logger.shutdown` only closes the queue —
// someone has to be on the other end to write what it hands over. Spawned
// after the listeners, a `concurrent` failure in between would leave those
// entries with no consumer and `group.cancel` nothing to drain, which is
// exactly the loss the teardown above exists to prevent.
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
+6
View File
@@ -231,6 +231,10 @@ pub const Logging = struct {
level: LogLevel = .info,
retention_days: u16 = 30,
query_log_buffer_max: u32 = 10000,
/// How long the query-log writer gathers entries before it commits them.
/// `0` does not wait at all: it flushes the entry that woke the writer plus
/// whatever is already queued.
query_log_flush_interval_s: u16 = 60,
hide_domains: bool = false,
hide_client_ips: bool = false,
output: LogOutput = .stderr,
@@ -565,6 +569,7 @@ const expected_keys = [_][]const u8{
"logging.max_size_mb",
"logging.output",
"logging.query_log_buffer_max",
"logging.query_log_flush_interval_s",
"logging.retention_days",
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -657,6 +662,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
.level = .err,
.retention_days = 41,
.query_log_buffer_max = 43,
.query_log_flush_interval_s = 44,
.hide_domains = true,
.hide_client_ips = true,
.output = .file,
+32
View File
@@ -93,6 +93,7 @@ pub const ValidateError = error{
BadTtl,
BadCacheSize,
BadRetention,
BadFlushInterval,
BadLogRotation,
BadDiskThresholds,
BadRateLimit,
@@ -335,6 +336,11 @@ const max_rate_window_seconds = 3_600;
/// the box, and nxdns does not try to know that.
const max_boot_entries = 1_000_000;
/// The ceiling on the query-log flush window. An hour of queries is already
/// more history than a crash is allowed to cost; beyond that the setting stops
/// being a batching knob and becomes a way to lose a working day of rows.
const max_flush_interval_s = 3_600;
fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
const up = cfg.upstream;
try checkTimeout(diags, up.attempt_timeout_ms, "upstream.attempt_timeout_ms");
@@ -464,6 +470,16 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
.{ max_boot_entries, cfg.logging.query_log_buffer_max },
);
}
// No floor: 0 is the documented "do not wait" setting, not a mistake.
if (cfg.logging.query_log_flush_interval_s > max_flush_interval_s) {
try diags.add(
error.BadFlushInterval,
"logging.query_log_flush_interval_s",
.{},
"must be at most {d}, got {d}",
.{ max_flush_interval_s, cfg.logging.query_log_flush_interval_s },
);
}
if (cfg.logging.max_size_mb < 1) {
try diags.add(error.BadLogRotation, "logging.max_size_mb", .{}, "must be at least 1", .{});
}
@@ -1946,6 +1962,22 @@ test "error.BadRetention" {
try expectProblem(huge_buffer, error.BadRetention, "logging.query_log_buffer_max");
}
test "error.BadFlushInterval" {
var cfg = baseConfig();
cfg.logging.query_log_flush_interval_s = 3601;
try expectProblem(cfg, error.BadFlushInterval, "logging.query_log_flush_interval_s");
// 0 is the "do not wait" setting and 3600 is the ceiling itself: both are
// legal, and a floor check would reject the first.
var immediate = baseConfig();
immediate.logging.query_log_flush_interval_s = 0;
try expectClean(immediate);
var edge = baseConfig();
edge.logging.query_log_flush_interval_s = max_flush_interval_s;
try expectClean(edge);
}
test "error.BadLogRotation" {
var cfg = baseConfig();
cfg.logging.max_files = 0;
+278 -32
View File
@@ -13,9 +13,19 @@
//! order; `QuerySink` calls them separately so both of its consumers see the
//! one transformed entry.
//!
//! One transaction per `logging.query_log_flush_interval_s`, not one per query.
//! At household rates a per-query commit costs orders of magnitude more disk
//! writes than the rows are worth — a minute of batching is what keeps an SD
//! card alive. The window is also what a crash costs: while the writer is
//! healthy and the disk gate is open, a process that dies loses about one
//! interval of query history. It is not a ceiling — a batch the gate is
//! holding, or one waiting on a write lock, is older than that — and nothing
//! here promises one, because query history is the cheapest data on the box.
//!
//! Log rows are expendable. A full queue drops the oldest unflushed entry, a
//! failed batch is dropped whole, and a disk that crossed the critical
//! threshold holds batches back indefinitely. Each of the three has a counter.
//! threshold holds batches back until it recovers — or, if the process is
//! already stopping, drops what it holds. Each of the three has a counter.
//! A writer that cannot prepare its statements closes the queue and marks
//! `writer_failed`, so the loss is visible rather than silent.
@@ -31,10 +41,10 @@ const queries_repo = @import("repositories/queries_repo.zig");
/// and the two names collide inside the struct.
const scope = std.log.scoped(.query_logger);
/// Flush tuning is comptime: §12.1 defines no configuration keys for it and a
/// household deployment has no reason to tune it.
/// Rows per transaction. Comptime: the window, not the batch size, is what an
/// operator has a reason to move, and a batch this size already amortizes the
/// commit at any household rate.
pub const flush_batch = 100;
pub const flush_interval_ms = 100;
/// What `hide_domains` and `hide_client_ips` store instead of the real value.
pub const hidden_marker = "hidden";
@@ -161,6 +171,14 @@ fn emptyAsNull(value: []const u8) ?[]const u8 {
const EntryQueue = std.Io.Queue(Entry);
/// A database failure is not in here: a batch the database refuses is dropped,
/// counted and reported where it happens, and the writer carries on.
/// `GatedAtShutdown` is the one condition `flush` cannot settle by itself —
/// the disk gate is shut and the process is stopping, so the batch in hand is
/// lost and so is everything still queued behind it. Only `runWriter` can see
/// both, so it does the counting and files the single episode.
pub const FlushError = std.Io.Cancelable || error{GatedAtShutdown};
/// What the flush interval race can produce. `Select` demands that each field
/// type match its task's return type exactly.
const Outcome = union(enum) {
@@ -178,6 +196,10 @@ pub const Logger = struct {
/// closed and every entry counts as dropped from that point, so a caller
/// that sees this must not expect rows.
writer_failed: std.atomic.Value(bool),
/// Set by `shutdown`, read by the disk gate. The gate holds a batch for as
/// long as the disk stays critical, which is right while the process is
/// running and a hang once it is stopping — nothing will ever release it.
draining: std.atomic.Value(bool),
/// Wired by the composition root after `init`, following the
/// `gate: ?*disk_monitor.Monitor` idiom. Null in every unit test here.
diagnostics: ?*events.Store = null,
@@ -195,6 +217,7 @@ pub const Logger = struct {
.rows_written = .init(0),
.batches_gated = .init(0),
.writer_failed = .init(false),
.draining = .init(false),
};
}
@@ -246,6 +269,11 @@ pub const Logger = struct {
/// whole life. Returns when `shutdown` closes the queue and the last batch
/// is flushed, or when the task is canceled.
///
/// It must outlive the producers rather than share their lifetime: a
/// cancellation that races the close decides at random whether the batch in
/// hand is written or counted as dropped. `app.zig` spawns this outside the
/// group it cancels for exactly that reason.
///
/// `monitor` is the §11.6 gate. Null disables gating.
pub fn runWriter(
self: *Logger,
@@ -262,7 +290,7 @@ pub const Logger = struct {
// life of the process, so the row stays active, which is the truth.
self.reportWrite(io, "writer", "preparing the batch statements failed", @errorName(err), 0);
self.queue.close(io);
self.dropRemaining(io);
_ = self.dropRemaining(io);
return;
};
defer writer.deinit();
@@ -275,10 +303,7 @@ pub const Logger = struct {
error.Closed => return,
error.Canceled => |e| return e,
};
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{
.raw = .fromMilliseconds(flush_interval_ms),
.clock = .awake,
});
const deadline = self.flushDeadline(io);
// `n` is live across both calls: entries already taken off the
// queue are lost if either one is canceled, so they must count.
var n: usize = 1;
@@ -286,17 +311,55 @@ pub const Logger = struct {
self.countDropped(n);
return err;
};
self.flush(io, &writer, batch[0..n], monitor) catch |err| {
self.countDropped(n);
return err;
self.flush(io, &writer, batch[0..n], monitor) catch |err| switch (err) {
error.Canceled => |e| {
self.countDropped(n);
return e;
},
// Nothing will open the gate now. Everything still queued is
// lost with the batch in hand, so it is counted here and
// announced once — a per-chunk report would write to the very
// disk that is out of space, dozens of times, on the way out.
error.GatedAtShutdown => {
self.countDropped(n);
const lost = n + self.dropRemaining(io);
scope.warn(
"query log: {d} rows dropped at shutdown, the disk gate was closed",
.{lost},
);
self.reportWrite(
io,
"batch",
"query log rows were dropped at shutdown",
"DiskCritical",
lost,
);
return;
},
};
}
}
/// Counts every entry left in a closed queue as dropped. The drain is
/// uncancelable: a cancellation racing the writer's own failure would
/// otherwise abandon the buffered entries without counting them.
fn dropRemaining(self: *Logger, io: std.Io) void {
/// When the batch that starts now must be committed. `.boot` and not
/// `.awake`: a suspended box would otherwise stretch the window by however
/// long it slept, and the rows are already in memory waiting.
///
/// An interval of 0 yields a deadline that has already passed, which is
/// exactly the documented sentinel — `fill` then takes what is queued and
/// returns without waiting for anything.
fn flushDeadline(self: *const Logger, io: std.Io) std.Io.Clock.Timestamp {
return .fromNow(io, .{
.raw = .fromSeconds(self.cfg.query_log_flush_interval_s),
.clock = .boot,
});
}
/// Counts every entry left in a closed queue as dropped, and returns how
/// many. The drain is uncancelable: a cancellation racing the writer's own
/// failure would otherwise abandon the buffered entries without counting
/// them.
fn dropRemaining(self: *Logger, io: std.Io) usize {
var total: usize = 0;
var leftover: [flush_batch]Entry = undefined;
while (true) {
const n = self.queue.getUncancelable(io, &leftover, 0) catch |err| switch (err) {
@@ -304,18 +367,24 @@ pub const Logger = struct {
};
if (n == 0) break;
self.countDropped(n);
total += n;
}
return total;
}
/// Closes the queue. `log` drops from here on and `runWriter` returns once
/// it has flushed what was left.
/// Closes the queue and tells the writer to stop waiting on anything that
/// may never arrive. `log` drops from here on, and `runWriter` returns once
/// it has flushed what was left — a blocked `getOne` on a closed queue
/// returns immediately, so the interval is never waited out at shutdown.
///
/// A writer held by the disk gate keeps holding: it flushes when the disk
/// recovers, and the `group.cancel` that follows this call in `app.zig`
/// stops a writer that will not wait. A canceled
/// writer counts the batch it holds under `queries_dropped`.
/// The close comes first: a writer that sees `draining` set must be able to
/// drain the queue to the end, and only a closed queue reports its end.
///
/// Every producer must be stopped and joined before this is called
/// (`app.zig`): an entry enqueued after the close is a dropped entry.
pub fn shutdown(self: *Logger, io: std.Io) void {
self.queue.close(io);
self.draining.store(true, .release);
}
/// Fills `batch` behind the entry already in slot 0, until it is full or
@@ -387,7 +456,7 @@ pub const Logger = struct {
writer: *queries_repo.BatchWriter,
entries: []const Entry,
monitor: ?*disk_monitor.Monitor,
) std.Io.Cancelable!void {
) FlushError!void {
if (entries.len == 0) return;
if (monitor) |m| {
@@ -396,6 +465,13 @@ pub const Logger = struct {
.clock = .awake,
};
while (!m.writesAllowed()) {
// Waiting for the disk to recover is right while the process
// runs and wrong once it is stopping: nothing is going to free
// space during shutdown, so the batch is lost either way and
// the only choice left is between losing it counted and
// hanging the exit. The counting and the one report belong to
// `runWriter`, which knows the rest of the queue is lost too.
if (self.draining.load(.acquire)) return error.GatedAtShutdown;
_ = self.batches_gated.fetchAdd(1, .monotonic);
try pause.sleep(io);
}
@@ -691,7 +767,7 @@ test "log after shutdown drops instead of blocking" {
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
}
test "the writer drains every entry and shutdown ends it" {
test "shutdown writes the batch the writer holds and the rest of the queue" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -700,7 +776,9 @@ test "the writer drains every entry and shutdown ends it" {
defer database.close();
var buf: [512]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
// An hour: nothing here can be explained by the window expiring. Every row
// that lands does so because the close released it.
var logger: Logger = .init(.{ .query_log_flush_interval_s = 3600 }, &buf);
var future = try io.concurrent(Logger.runWriter, .{
&logger,
@@ -714,6 +792,10 @@ test "the writer drains every entry and shutdown ends it" {
const written = try std.fmt.bufPrint(name, "d{d}.example", .{i % 10});
logger.log(io, sampleEntry(@intCast(i), written));
}
// The app.zig order: the only producer is this task and it is done, so the
// close cannot lose an entry, and the writer is awaited rather than
// canceled.
logger.shutdown(io);
try future.await(io);
@@ -723,7 +805,57 @@ test "the writer drains every entry and shutdown ends it" {
try testing.expectEqual(@as(i64, 10), try queries_repo.countDomains(&database));
}
test "the writer flushes an entry once the interval passes" {
test "entries that arrive inside one window reach the database in one batch" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var writer = try queries_repo.BatchWriter.init(&database);
defer writer.deinit();
var buf: [16]Entry = undefined;
var logger: Logger = .init(.{ .query_log_flush_interval_s = 3600 }, &buf);
for (0..6) |i| logger.log(io, sampleEntry(@intCast(i), "batched.example"));
// No further producer exists, so the close is what ends the fill — the same
// thing that ends it at shutdown.
logger.shutdown(io);
var batch: [flush_batch]Entry = undefined;
batch[0] = try logger.queue.getOne(io);
var n: usize = 1;
try logger.fill(io, &batch, logger.flushDeadline(io), &n);
try testing.expectEqual(@as(usize, 6), n);
// One `writeBatch` call, which is one transaction (`queries_repo.zig`).
try logger.flush(io, &writer, batch[0..n], null);
try testing.expectEqual(@as(u64, 6), logger.rows_written.load(.monotonic));
try testing.expectEqual(@as(i64, 6), try queries_repo.countRows(&database));
}
test "a zero interval takes what is queued and waits for nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var buf: [8]Entry = undefined;
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
logger.log(io, sampleEntry(1, "now.example"));
logger.log(io, sampleEntry(2, "now.example"));
var batch: [flush_batch]Entry = undefined;
batch[0] = try logger.queue.getOne(io);
var n: usize = 1;
// The queue is open and the batch has room: any non-zero interval blocks
// here until it expires. Zero returns with what was already queued.
try logger.fill(io, &batch, logger.flushDeadline(io), &n);
try testing.expectEqual(@as(usize, 2), n);
}
test "the writer holds an entry for the length of the flush interval" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -732,7 +864,7 @@ test "the writer flushes an entry once the interval passes" {
defer database.close();
var buf: [8]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
var logger: Logger = .init(.{ .query_log_flush_interval_s = 1 }, &buf);
var future = try io.concurrent(Logger.runWriter, .{
&logger,
@@ -743,20 +875,71 @@ test "the writer flushes an entry once the interval passes" {
logger.log(io, sampleEntry(1, "only.example"));
// Sampled, not asserted, while the writer runs: an assertion that fails
// here would return before the writer is stopped, and `Threaded.deinit`
// then waits on a task nothing will ever end.
const quarter: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(250), .clock = .awake };
try quarter.sleep(io);
const written_at_a_quarter = logger.rows_written.load(.monotonic);
// Three times the interval. A flush that has not happened by then is a
// failure, not slowness.
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
var waited: usize = 0;
while (logger.rows_written.load(.monotonic) == 0) : (waited += 1) {
// Ten times the interval; a flush that has not happened by then is a
// failure, not slowness.
try testing.expect(waited < 200);
while (logger.rows_written.load(.monotonic) == 0 and waited < 600) : (waited += 1) {
try poll.sleep(io);
}
logger.shutdown(io);
try future.await(io);
// A writer that commits per query has already written at a quarter of the
// window; this one has not.
try testing.expectEqual(@as(u64, 0), written_at_a_quarter);
try testing.expect(waited < 600);
try testing.expectEqual(@as(u64, 1), logger.rows_written.load(.monotonic));
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
}
test "a full batch flushes without waiting for the interval" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var buf: [256]Entry = undefined;
var logger: Logger = .init(.{ .query_log_flush_interval_s = 3600 }, &buf);
var future = try io.concurrent(Logger.runWriter, .{
&logger,
io,
&database,
@as(?*disk_monitor.Monitor, null),
});
for (0..150) |i| logger.log(io, sampleEntry(@intCast(i), "burst.example"));
// The window is an hour away, so only a full batch can release a flush.
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
var waited: usize = 0;
while (logger.rows_written.load(.monotonic) < flush_batch and waited < 400) : (waited += 1) {
try poll.sleep(io);
}
// Sampled with the writer still running, asserted once it has stopped.
const written_before_shutdown = logger.rows_written.load(.monotonic);
logger.shutdown(io);
try future.await(io);
// Exactly one batch went out early, and the 50 behind it waited for the
// close rather than for the hour.
try testing.expect(waited < 400);
try testing.expectEqual(@as(u64, flush_batch), written_before_shutdown);
try testing.expectEqual(@as(i64, 150), try queries_repo.countRows(&database));
}
test "a gated flush holds the batch until the disk recovers" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -867,7 +1050,9 @@ test "a canceled writer counts the batch it was holding" {
defer database.close();
var buf: [8]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
// Zero interval: the writer reaches the gate with what is queued instead of
// waiting a minute for a producer that no longer exists.
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
@@ -898,6 +1083,67 @@ test "a canceled writer counts the batch it was holding" {
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
}
test "a disk-gated writer drops what it holds at shutdown instead of hanging" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
var buf: [512]Entry = undefined;
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
logger.diagnostics = &fx.store;
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
logger.log(io, sampleEntry(1, "held.example"));
logger.log(io, sampleEntry(2, "held.example"));
var future = try io.concurrent(Logger.runWriter, .{
&logger,
io,
&database,
@as(?*disk_monitor.Monitor, &monitor),
});
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
var waited: usize = 0;
while (logger.batches_gated.load(.monotonic) == 0 and waited < 400) : (waited += 1) {
try poll.sleep(io);
}
// Two and a half chunks wait behind the batch the gate is holding, so a
// drain that reported per chunk would file three episodes' worth of writes
// to the disk that is out of space.
const queued = 250;
for (0..queued) |i| logger.log(io, sampleEntry(@intCast(i + 3), "queued.example"));
// The disk never recovers. The wait still ends, and every entry is counted.
logger.shutdown(io);
try future.await(io);
try testing.expect(waited < 400);
try testing.expectEqual(@as(u64, queued + 2), logger.queries_dropped.load(.monotonic));
try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
// One episode, reported once, and its detail carries the whole loss rather
// than the size of whichever chunk was last.
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT occurrences FROM operational_events"));
try testing.expectEqualStrings("batch", try fx.text("SELECT subject_key FROM operational_events"));
try testing.expectEqualStrings(
"query log rows were dropped at shutdown: DiskCritical (252 rows)",
try fx.text("SELECT detail FROM operational_events"),
);
}
test "an empty batch touches neither the database nor the counters" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+16 -7
View File
@@ -228,7 +228,11 @@ test "S8 case 2: a single entry reaches the file once the flush interval passes"
defer log_db.deinit();
var queue_buf: [8]logger.Entry = undefined;
var query_log: logger.Logger = .init(.{}, &queue_buf);
// A one-second window, not the 60-second default: this case is about the
// entry reaching the file on the interval alone, with nothing to close the
// queue for it.
const flush_interval_s = 1;
var query_log: logger.Logger = .init(.{ .query_log_flush_interval_s = flush_interval_s }, &queue_buf);
var future = try io.concurrent(logger.Logger.runWriter, .{
&query_log,
@@ -239,9 +243,9 @@ test "S8 case 2: a single entry reaches the file once the flush interval passes"
query_log.log(io, entryAt(1, "only.example"));
// Ten flush intervals of headroom: a row that has not landed by then is a
// Three flush intervals of headroom: a row that has not landed by then is a
// failure of the interval race, not a slow machine.
const limit = 10 * logger.flush_interval_ms / 5;
const limit = 3 * flush_interval_s * 1000 / 5;
try awaitCount(&query_log.rows_written, 1, limit);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(log_db.database()));
@@ -268,7 +272,9 @@ test "S8 case 3: a full queue drops the oldest entries and the newest survive" {
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
var queue_buf: [8]logger.Entry = undefined;
var query_log: logger.Logger = .init(.{}, &queue_buf);
// Zero interval: this case is about the queue cap and the gate, so the
// writer takes what is queued and goes straight to the gate.
var query_log: logger.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf);
// The whole burst is enqueued before the writer starts. A writer already
// draining the queue would take entries out of it mid-burst and make the
@@ -286,8 +292,9 @@ test "S8 case 3: a full queue drops the oldest entries and the newest survive" {
try awaitCount(&query_log.batches_gated, 1, 200);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
// Un-gate before the shutdown: a writer held by the disk gate holds its
// batch, and `shutdown` alone would never release it.
// Un-gate before the shutdown: a writer still held by the gate counts its
// batch as dropped rather than writing it, which is a different case (the
// logger's own suite covers it).
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
try awaitCount(&query_log.rows_written, 8, 300);
query_log.shutdown(io);
@@ -403,7 +410,9 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
try testing.expect(monitor.gauges().db_bytes > 0);
var queue_buf: [64]logger.Entry = undefined;
var query_log: logger.Logger = .init(.{}, &queue_buf);
// Zero interval: the case is the gate's hold and release, so the writer
// takes the five queued entries straight to it.
var query_log: logger.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf);
for (0..5) |i| query_log.log(io, entryAt(@intCast(i), "gated.example"));
var future = try io.concurrent(logger.Logger.runWriter, .{
+2
View File
@@ -203,6 +203,7 @@ const LoggingView = struct {
level: []const u8,
retention_days: u16,
query_log_buffer_max: u32,
query_log_flush_interval_s: u16,
hide_domains: bool,
hide_client_ips: bool,
output: []const u8,
@@ -263,6 +264,7 @@ pub fn view(cfg: model.Config) View {
.level = cfg.logging.level.toDb(),
.retention_days = cfg.logging.retention_days,
.query_log_buffer_max = cfg.logging.query_log_buffer_max,
.query_log_flush_interval_s = cfg.logging.query_log_flush_interval_s,
.hide_domains = cfg.logging.hide_domains,
.hide_client_ips = cfg.logging.hide_client_ips,
.output = cfg.logging.output.toDb(),
+3 -1
View File
@@ -2503,13 +2503,14 @@ components:
enum: [strip, forward]
logging:
type: object
required: [level, retention_days, query_log_buffer_max, hide_domains, hide_client_ips, output, file_path, max_size_mb, max_files]
required: [level, retention_days, query_log_buffer_max, query_log_flush_interval_s, hide_domains, hide_client_ips, output, file_path, max_size_mb, max_files]
properties:
level:
type: string
enum: [error, warn, info, debug]
retention_days: { type: integer }
query_log_buffer_max: { type: integer }
query_log_flush_interval_s: { type: integer }
hide_domains: { type: boolean }
hide_client_ips: { type: boolean }
output:
@@ -2651,6 +2652,7 @@ components:
level: { type: string }
retention_days: { type: integer }
query_log_buffer_max: { type: integer }
query_log_flush_interval_s: { type: integer }
hide_domains: { type: boolean }
hide_client_ips: { type: boolean }
output: { type: string }
+1
View File
@@ -598,6 +598,7 @@ const SettingsView = struct {
level: []const u8,
retention_days: u16,
query_log_buffer_max: u32,
query_log_flush_interval_s: u16,
hide_domains: bool,
hide_client_ips: bool,
output: []const u8,