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
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:
+26
-19
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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, .{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user