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
+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();