Files
nxdns/src/storage/logger_controller.zig
T

1058 lines
42 KiB
Zig

//! The stable owner of the query logger (milestone 34 §S4).
//!
//! `logging.query_log_buffer_max` is the queue's capacity, and the queue is
//! allocated once at boot. Changing it means a new buffer, a new `Logger`, a
//! new writer task and a new database connection — a whole new generation. The
//! controller is what everything else holds instead of the `Logger`, so a
//! resize swaps the generation underneath `QuerySink`, `/metrics` and
//! `/api/health` without any of them learning that generations exist.
//!
//! Three invariants make that safe, and each has a mechanism:
//!
//! 1. **No producer ever enqueues into a closed queue.** A producer borrows the
//! live generation through `acquire`/`release`; retirement waits for every
//! borrow of the old generation to be released before it closes that queue.
//! There is no resize drop window, and no resize-specific drop counter — if
//! one were needed, this file would be wrong.
//! 2. **Publish cannot fail.** Everything fallible — the buffer, the `Logger`,
//! the connection, the writer task and its prepared statements — happens in
//! `prepare`, before the settings transaction commits. The new writer is
//! spawned already parked on an activation gate, so `publish` is a pointer
//! swap and one `Event.set`.
//! 3. **One task joins each writer.** `Future.await` is not thread-safe
//! (`Io.zig:1198`), so the reaper — spawned once at construction — is the
//! sole joiner of retired writers, and the shutdown path is the sole joiner
//! of the live one. At most one retired generation exists at a time, which
//! is what bounds writers, connections and buffers; a second resize while
//! one is still draining is refused rather than queued.
//!
//! What must stay continuous across a swap lives here rather than in a
//! generation: the counters, the disk-gate episode and the drop stamp are
//! summed and merged across the outstanding generations, and a retired
//! generation's finals fold into `base` the moment its writer is joined, so no
//! reader ever sees a counter go backwards. `writer_failed` is the exception
//! and reports the LIVE writer only — a successful resize means a writer that
//! prepared cleanly is now serving, which is the truth the health endpoint
//! needs; the rows a failed retired writer lost are already in the drop count.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
const events = @import("events.zig");
const logger = @import("logger.zig");
const model = @import("../config/model.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const querylog_schema = @import("querylog_schema.zig");
const scope = std.log.scoped(.query_logger);
/// Where a generation's writer connection comes from. The controller opens one
/// per generation and closes it after that generation's writer is joined: two
/// writers must never share a `db.Db`.
pub const Source = struct {
dir: std.Io.Dir,
path: [:0]const u8,
};
/// The cumulative totals of every generation whose writer has been joined.
/// Live generations are added to these on every read.
const Totals = struct {
queries_dropped: u64 = 0,
rows_written: u64 = 0,
batches_gated: u64 = 0,
last_drop_s: i64 = 0,
fn fold(self: *Totals, lg: *const logger.Logger) void {
self.queries_dropped += lg.queries_dropped.load(.monotonic);
self.rows_written += lg.rows_written.load(.monotonic);
self.batches_gated += lg.batches_gated.load(.monotonic);
self.last_drop_s = @max(self.last_drop_s, lg.last_drop_s.load(.monotonic));
}
};
/// What a facade reader sees: one coherent reading of every outstanding
/// generation, taken under the controller's mutex.
pub const Sample = struct {
queries_dropped: u64,
rows_written: u64,
batches_gated: u64,
/// Null while nothing has been dropped, exactly as `Logger.lastDropSeconds`.
last_drop_s: ?i64,
/// The LIVE writer's state. A retired writer's failures are in the counters.
writer_failed: bool,
/// The worst episode any outstanding generation is in. A retired writer
/// still holding a batch against a full disk is a current fault whether or
/// not the live one has noticed the disk yet.
gate_episode: logger.GateEpisode,
};
/// One buffer, one `Logger`, one writer task and one connection, with the
/// producer refcount that decides when all four can go away.
///
/// Heap-allocated and never moved: the queue holds waiting tasks in intrusive
/// lists, and `logger` points into `owned`.
pub const Generation = struct {
/// The producers' view. Points at `owned.state` for a generation this
/// controller built, and at the caller's `Logger` for a borrowed one.
logger: *logger.Logger,
/// How many producers are inside `transformed`+`logTransformed` right now.
/// Guarded by the controller's mutex.
borrows: usize = 0,
/// Set by `publish` under the controller's mutex.
retired: bool = false,
/// Null for a borrowed generation, which owns nothing and is never retired.
owned: ?Owned = null,
const Owned = struct {
/// Pointed at by `Generation.logger`. Never moved.
state: logger.Logger,
queue_buf: []logger.Entry,
database: db.Db,
writer: std.Io.Future(std.Io.Cancelable!void),
/// Set by the writer once its statements are prepared, or once
/// preparing them has failed. `prepare` waits on it.
ready: std.Io.Event = .unset,
/// Opened by `publish`, or by `abandon` to release a writer that will
/// never serve. The writer consumes nothing until one of the two.
activation: std.Io.Event = .unset,
/// What `BatchWriter.init` refused with, read after `ready`.
prepare_error: ?db.Error = null,
/// Set before `activation` when the candidate is thrown away.
aborted: bool = false,
capacity: u32,
};
fn deinit(self: *Generation, gpa: Allocator) void {
if (self.owned) |*owned| {
owned.database.close();
gpa.free(owned.queue_buf);
}
gpa.destroy(self);
}
};
pub const InitError = Allocator.Error || std.Io.ConcurrentError;
pub const Options = struct {
gpa: Allocator,
/// The connection `cli.DataDir.openQuerylogDb` established, moved in. The
/// controller closes it when the boot generation retires or the process
/// stops.
database: db.Db,
/// Where later generations open their own connections. The controller is
/// resizable only when this is set.
source: ?Source,
logging: model.Logging,
monitor: ?*disk_monitor.Monitor = null,
diagnostics: ?*events.Store = null,
};
/// What `prepare` refuses with. Every variant leaves the running logger and the
/// database untouched.
pub const PrepareError = Allocator.Error || db.Error || std.Io.ConcurrentError || error{
/// `config/validate.zig`'s floor.
BufferTooSmall,
/// `config/validate.zig`'s ceiling, `logger.query_log_buffer_max`.
BufferTooLarge,
/// A previous resize's generation is still draining. At most one retired
/// generation may exist, so this bounds writers, connections and buffers
/// rather than queueing an unbounded chain of them.
PreviousResizeDraining,
/// The candidate writer could not prepare its statements.
WriterStatementsFailed,
/// A borrowed controller owns no generation to replace.
NotResizable,
};
/// `config/validate.zig`'s own wording for a refused buffer size, so a settings
/// change and a startup rejection say the same thing about the same value.
/// Returns null for the failures that are not about the size.
pub fn sizeMessage(err: PrepareError, requested: u32, buf: []u8) ?[]const u8 {
return switch (err) {
error.BufferTooSmall => "must be at least 1",
error.BufferTooLarge => std.fmt.bufPrint(
buf,
"must be at most {d}, got {d}",
.{ logger.query_log_buffer_max, requested },
) catch buf[0..],
else => null,
};
}
/// A generation built, connected and parked, waiting for a commit that has not
/// happened yet. Exactly one of `publish` or `abandon` must consume it.
pub const Prepared = struct {
generation: *Generation,
};
pub const Controller = struct {
gpa: Allocator,
source: ?Source,
monitor: ?*disk_monitor.Monitor,
diagnostics: ?*events.Store,
/// Guards `live`, `retired`, `phase`, `stopping`, `base` and every
/// generation's `borrows`. Held briefly and never across an await — the
/// CertStore discipline (`cert_store.zig:199`).
mutex: std.Io.Mutex = .init,
/// Signalled whenever the reaper's work might have changed: a retirement
/// was published, a borrow was released, or shutdown began.
progress: std.Io.Condition = .init,
live: *Generation,
/// The one generation a resize may leave behind.
retired: ?*Generation = null,
phase: Phase = .idle,
stopping: bool = false,
stopped: bool = false,
base: Totals = .{},
/// Null for a borrowed controller. The sole joiner of retired writers.
reaper: ?std.Io.Future(void) = null,
const Phase = enum {
idle,
/// A retired generation is waiting for its last producer borrow.
waiting_borrows,
/// The reaper owns the retired generation and is joining its writer.
draining,
};
/// Builds the boot generation around `opts.database` and starts its writer
/// and the reaper.
///
/// The boot writer takes the plain `Logger.runWriter` path rather than the
/// parked one: a process that cannot prepare its statements at boot still
/// serves DNS with `writer_failed` set, which is today's behaviour and the
/// right one — refusing to start would cost more than the log rows do.
/// Only a resize can afford to refuse.
pub fn init(self: *Controller, io: std.Io, opts: Options) InitError!void {
var database = opts.database;
const generation = build(opts.gpa, database, opts.logging, opts.diagnostics) catch |err| {
database.close();
return err;
};
errdefer generation.deinit(opts.gpa);
const owned = &generation.owned.?;
self.* = .{
.gpa = opts.gpa,
.source = opts.source,
.monitor = opts.monitor,
.diagnostics = opts.diagnostics,
.live = generation,
};
owned.writer = try io.concurrent(logger.Logger.runWriter, .{
generation.logger,
io,
opts.gpa,
&owned.database,
opts.monitor,
});
errdefer {
generation.logger.shutdown(io);
owned.writer.await(io) catch {};
}
self.reaper = try io.concurrent(runReaper, .{ self, io });
}
/// Stops every generation in the order the writer deadlock demands, and
/// joins every task this controller started.
///
/// Producers must already be quiesced (`app.zig` cancels their group
/// first): an entry enqueued after the close is a dropped entry, and a
/// borrow still held here would stall the reaper.
///
/// The order is today's safe order, once per generation. The live queue
/// closes before its `draining` flag is set, because a writer that sees
/// `draining` must still be able to reach the end of its queue and only a
/// closed queue reports its end. The retired generation gets `draining`
/// too — without it a retired writer parked on a shut disk gate waits for a
/// recovery that is never coming, and the reaper would never join it.
/// Idempotent: the teardown path and `deinit` both call it.
pub fn shutdown(self: *Controller, io: std.Io) void {
if (self.stopped) return;
self.stopped = true;
self.mutex.lockUncancelable(io);
const live = self.live;
if (live.owned == null) {
self.mutex.unlock(io);
return;
}
live.logger.shutdown(io);
if (self.retired) |old| old.logger.draining.store(true, .release);
self.stopping = true;
self.progress.broadcast(io);
self.mutex.unlock(io);
live.owned.?.writer.await(io) catch {};
if (self.reaper) |*reaper| {
reaper.await(io);
self.reaper = null;
}
}
/// Frees the live generation and its connection. Stops everything first if
/// the caller has not.
pub fn deinit(self: *Controller, io: std.Io) void {
self.shutdown(io);
std.debug.assert(self.retired == null);
if (self.live.owned != null) self.live.deinit(self.gpa);
}
// -----------------------------------------------------------------------
// the producer side
// -----------------------------------------------------------------------
/// Pins the live generation for the length of one entry's transform and
/// enqueue. Every `acquire` must be paired with a `release`.
pub fn acquire(self: *Controller, io: std.Io) *Generation {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.live.borrows += 1;
return self.live;
}
/// Releases a borrow. The last borrow of a retired generation is what lets
/// the reaper close that queue.
pub fn release(self: *Controller, io: std.Io, generation: *Generation) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
std.debug.assert(generation.borrows > 0);
generation.borrows -= 1;
if (generation.retired and generation.borrows == 0) self.progress.broadcast(io);
}
// -----------------------------------------------------------------------
// the live-configuration setters (milestone 34 §S1, addressed here so they
// survive a resize)
// -----------------------------------------------------------------------
/// Installs the privacy policy on every outstanding generation.
///
/// Each generation keeps its own packed word and a producer still decides
/// one entry from one load of the generation it borrowed, so a mixed policy
/// remains impossible: the two stores below are to two different queues'
/// producers, and no producer reads both.
pub fn setPrivacy(self: *Controller, io: std.Io, p: logger.Privacy) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.live.logger.setPrivacy(p);
if (self.retired) |old| old.logger.setPrivacy(p);
}
pub fn setFlushInterval(self: *Controller, io: std.Io, seconds: u16) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.live.logger.setFlushInterval(seconds);
if (self.retired) |old| old.logger.setFlushInterval(seconds);
}
// -----------------------------------------------------------------------
// the facade's read side
// -----------------------------------------------------------------------
/// Every counter and state the metrics and health surfaces read, in one
/// coherent reading across the outstanding generations.
pub fn sample(self: *Controller, io: std.Io) Sample {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
var totals = self.base;
totals.fold(self.live.logger);
var episode = self.live.logger.gateEpisode();
if (self.retired) |old| {
totals.fold(old.logger);
episode = @enumFromInt(@max(@intFromEnum(episode), @intFromEnum(old.logger.gateEpisode())));
}
return .{
.queries_dropped = totals.queries_dropped,
.rows_written = totals.rows_written,
.batches_gated = totals.batches_gated,
.last_drop_s = if (totals.last_drop_s == 0) null else totals.last_drop_s,
.writer_failed = self.live.logger.writer_failed.load(.acquire),
.gate_episode = episode,
};
}
/// Whether a previous resize's generation is still outstanding. This is
/// what `prepare` refuses on, and what a caller polls to know when the one
/// retirement slot is free again.
pub fn retirementPending(self: *Controller, io: std.Io) bool {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.retired != null;
}
/// The live queue's capacity, which is what a resize changes.
pub fn capacity(self: *Controller, io: std.Io) u32 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const owned = self.live.owned orelse return @intCast(self.live.logger.queue.capacity());
return owned.capacity;
}
// -----------------------------------------------------------------------
// resize: prepare → (the caller commits) → publish, or abandon
// -----------------------------------------------------------------------
/// Builds a complete replacement generation and parks its writer. Fallible
/// from end to end and side-effect free: on any failure nothing has been
/// published, no connection is left open and the running logger has not
/// been touched.
///
/// The caller commits its database row only after this returns, and then
/// calls exactly one of `publish` or `abandon`.
///
/// `cfg` is the FINAL MERGED logging configuration, not the live one: the
/// same write that resizes the queue may also change the privacy flags or
/// the flush interval, and those are published against the generation this
/// candidate is about to replace. Seeding from the live values would leave
/// the replacement carrying the policy the write just retired.
pub fn prepare(self: *Controller, io: std.Io, cfg: model.Logging) PrepareError!Prepared {
const entries = cfg.query_log_buffer_max;
if (entries < 1) return error.BufferTooSmall;
if (entries > logger.query_log_buffer_max) return error.BufferTooLarge;
const source = self.source orelse return error.NotResizable;
{
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.retired != null) return error.PreviousResizeDraining;
}
var database = try querylog_schema.reopen(io, source.dir, source.path);
const generation = build(self.gpa, database, cfg, self.diagnostics) catch |err| {
database.close();
return err;
};
errdefer generation.deinit(self.gpa);
const owned = &generation.owned.?;
owned.writer = try io.concurrent(runParkedWriter, .{ generation, io, self.gpa, self.monitor });
// The statements are prepared before anything is published, so a
// failure here is a refused settings change rather than a writer that
// dies under a queue producers are already filling.
owned.ready.waitUncancelable(io);
if (owned.prepare_error) |err| {
owned.writer.await(io) catch {};
scope.warn(
"query logger resize: preparing the batch statements failed: {s}",
.{@errorName(err)},
);
// `database` is closed by the errdefers above, which still cover
// everything this function allocated.
return error.WriterStatementsFailed;
}
return .{ .generation = generation };
}
/// Throws a prepared generation away: the transaction it was built for did
/// not commit. Releases the parked writer, joins it and closes everything.
pub fn abandon(self: *Controller, io: std.Io, prepared: Prepared) void {
const owned = &prepared.generation.owned.?;
owned.aborted = true;
owned.activation.set(io);
owned.writer.await(io) catch {};
prepared.generation.deinit(self.gpa);
}
/// Infallible and I/O-free: swap the live pointer, mark the old generation
/// retired, open the new writer's activation gate. Nothing is closed,
/// joined or freed here — that is the reaper's work, and it happens after
/// the last producer of the old generation releases it.
pub fn publish(self: *Controller, io: std.Io, prepared: Prepared) void {
const fresh = prepared.generation;
self.mutex.lockUncancelable(io);
const old = self.live;
self.live = fresh;
old.retired = true;
self.retired = old;
self.phase = .waiting_borrows;
self.progress.broadcast(io);
self.mutex.unlock(io);
fresh.owned.?.activation.set(io);
}
// -----------------------------------------------------------------------
// the reaper
// -----------------------------------------------------------------------
/// The sole joiner of retired writers, for the controller's whole life.
///
/// `publish` never spawns: a spawn can fail, and publish may not. This task
/// exists from construction and waits for work instead.
fn runReaper(self: *Controller, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
while (true) {
if (self.phase == .waiting_borrows and self.retired.?.borrows == 0) {
const old = self.retired.?;
self.phase = .draining;
self.mutex.unlock(io);
drain(old, io);
self.mutex.lockUncancelable(io);
// Folded before the generation is freed, so the totals never
// dip: a reader between the fold and the free sees the same
// numbers either way.
self.base.fold(old.logger);
self.retired = null;
self.phase = .idle;
old.deinit(self.gpa);
self.progress.broadcast(io);
continue;
}
if (self.stopping and self.retired == null) return;
self.progress.waitUncancelable(io, &self.mutex);
}
}
};
/// One heap-stable generation with its writer future still unset. Takes
/// ownership of `database`.
fn build(
gpa: Allocator,
database: db.Db,
cfg: model.Logging,
diagnostics: ?*events.Store,
) Allocator.Error!*Generation {
const generation = try gpa.create(Generation);
errdefer gpa.destroy(generation);
const queue_buf = try gpa.alloc(logger.Entry, cfg.query_log_buffer_max);
errdefer gpa.free(queue_buf);
generation.* = .{
.logger = undefined,
.owned = .{
.state = .init(cfg, queue_buf),
.queue_buf = queue_buf,
.database = database,
.writer = undefined,
.capacity = cfg.query_log_buffer_max,
},
};
const owned = &generation.owned.?;
generation.logger = &owned.state;
owned.state.diagnostics = diagnostics;
return generation;
}
/// Closes a retired generation's queue, waits for its writer to empty it, and
/// closes its connection. Runs with the controller's mutex released: it awaits.
fn drain(generation: *Generation, io: std.Io) void {
const owned = &generation.owned.?;
// `retire`, not `shutdown`: with `draining` set a gate-held writer would
// take the `GatedAtShutdown` path and DROP the batch it is holding
// (`logger.zig`). A replaced generation is not a stopping process — the
// disk can still recover, and those rows are still going to be written.
generation.logger.retire(io);
owned.writer.await(io) catch {};
}
/// A resize generation's writer: prepare, report, park, then serve.
fn runParkedWriter(
generation: *Generation,
io: std.Io,
gpa: Allocator,
monitor: ?*disk_monitor.Monitor,
) std.Io.Cancelable!void {
const owned = &generation.owned.?;
var writer = queries_repo.BatchWriter.init(gpa, &owned.database) catch |err| {
owned.prepare_error = err;
owned.ready.set(io);
return;
};
defer writer.deinit();
owned.ready.set(io);
// Nothing is consumed until `publish` or `abandon` decides. The queue this
// writer will serve has no producers yet either — it is not live.
owned.activation.waitUncancelable(io);
if (owned.aborted) return;
return generation.logger.runPrepared(io, &writer, monitor);
}
/// A controller over a `Logger` the caller already owns: no connection, no
/// writer, no reaper and no resize. Owns nothing, so there is nothing to tear
/// down; both parts are inline so a caller keeps them on its stack beside the
/// sink — `upstream/owner.zig`'s `Borrowed`.
pub const Borrowed = struct {
generation: Generation = undefined,
controller: Controller = undefined,
pub fn over(self: *Borrowed, lg: *logger.Logger) *Controller {
self.generation = .{ .logger = lg };
self.controller = .{
.gpa = undefined,
.source = null,
.monitor = null,
.diagnostics = null,
.live = &self.generation,
};
return &self.controller;
}
};
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so
/// every call into the code under test uses the same `Io` instance — an
/// `Io.Threaded`, which is what makes `io.concurrent` available to the writers.
const test_io = testing.io;
/// Where `std.testing.tmpDir` puts its directories.
const tmp_prefix = ".zig-cache/tmp/";
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
/// A real `querylog.db` in a temporary directory with a controller over it.
///
/// Must not be moved once `start` has run: the reaper holds `&self.controller`,
/// and the queue keeps waiting tasks in intrusive lists.
const Harness = struct {
tmp: testing.TmpDir = undefined,
path_buf: [tmp_prefix.len + sub_path_len + "/querylog.db".len + 1]u8 = undefined,
path: [:0]const u8 = undefined,
monitor: ?*disk_monitor.Monitor = null,
controller: Controller = undefined,
cfg: model.Logging = .{},
fn start(self: *Harness, cfg: model.Logging) !void {
self.cfg = cfg;
self.tmp = testing.tmpDir(.{});
errdefer self.tmp.cleanup();
var root_buf: [tmp_prefix.len + sub_path_len]u8 = undefined;
@memcpy(root_buf[0..tmp_prefix.len], tmp_prefix);
@memcpy(root_buf[tmp_prefix.len..], &self.tmp.sub_path);
self.path = try std.fmt.bufPrintZ(&self.path_buf, "{s}/querylog.db", .{&root_buf});
var opened = try querylog_schema.open(test_io, std.Io.Dir.cwd(), self.path);
errdefer opened.database.close();
try self.controller.init(test_io, .{
.gpa = testing.allocator,
.database = opened.database,
.source = .{ .dir = std.Io.Dir.cwd(), .path = self.path },
.logging = cfg,
.monitor = self.monitor,
});
}
fn stop(self: *Harness) void {
self.controller.deinit(test_io);
self.tmp.cleanup();
}
/// A resize of the started configuration, which is what a settings write
/// hands `prepare`: the whole merged section with one field moved.
fn prepareResize(self: *Harness, entries: u32) PrepareError!Prepared {
var cfg = self.cfg;
cfg.query_log_buffer_max = entries;
return self.controller.prepare(test_io, cfg);
}
/// The rows on disk, read through a connection of its own after the
/// controller has closed every one of its own.
fn storedRows(self: *Harness) !i64 {
var database = try querylog_schema.reopen(test_io, std.Io.Dir.cwd(), self.path);
defer database.close();
return queries_repo.countRows(&database);
}
/// A connection for a test that needs to change the schema under a running
/// controller.
fn sideConnection(self: *Harness) !db.Db {
return querylog_schema.reopen(test_io, std.Io.Dir.cwd(), self.path);
}
};
fn testEntry(timestamp: i64, domain: []const u8) logger.Entry {
return .init(.{
.timestamp = timestamp,
.domain = domain,
.client_ip = "192.0.2.10",
.qtype = 1,
.blocked = false,
.response_time_us = 900,
.cache_hit = false,
.upstream = "9.9.9.9",
});
}
/// One producer, following the borrow protocol `QuerySink` follows.
fn produce(controller: *Controller, count: usize, base: i64) void {
for (0..count) |i| {
const generation = controller.acquire(test_io);
defer controller.release(test_io, generation);
generation.logger.log(test_io, testEntry(base + @as(i64, @intCast(i)), "producer.example"));
}
}
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
fn awaitRows(controller: *Controller, target: u64, limit: usize) !void {
var polls: usize = 0;
while (controller.sample(test_io).rows_written < target) : (polls += 1) {
try testing.expect(polls < limit);
try poll.sleep(test_io);
}
}
fn awaitGated(controller: *Controller, limit: usize) !void {
var polls: usize = 0;
while (controller.sample(test_io).batches_gated == 0) : (polls += 1) {
try testing.expect(polls < limit);
try poll.sleep(test_io);
}
}
fn awaitRetired(controller: *Controller, limit: usize) !void {
var polls: usize = 0;
while (controller.retirementPending(test_io)) : (polls += 1) {
try testing.expect(polls < limit);
try poll.sleep(test_io);
}
}
fn critical() disk_monitor.Monitor {
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
return monitor;
}
test "a resize under concurrent producers loses nothing and accounts for every entry" {
var h: Harness = .{};
// Room for every entry all four producers can write: an overflow drop here
// would be ordinary backpressure, and this case is about the swap.
try h.start(.{ .query_log_buffer_max = 4096, .query_log_flush_interval_s = 0 });
defer h.stop();
const producers = 4;
const each = 200;
var futures: [producers]std.Io.Future(void) = undefined;
var started: usize = 0;
// Declared before the spawn loop so a partial spawn still joins what ran.
defer for (futures[0..started]) |*future| future.await(test_io);
while (started < producers) : (started += 1) {
futures[started] = try test_io.concurrent(produce, .{
&h.controller,
each,
@as(i64, @intCast(started)) * each,
});
}
// The swap happens with all four producers mid-flight. Every entry either
// reaches the old queue before its close or the new one after the publish;
// no third outcome exists, which is what "no resize drop window" means.
const prepared = try h.prepareResize(8192);
h.controller.publish(test_io, prepared);
try testing.expectEqual(@as(u32, 8192), h.controller.capacity(test_io));
for (futures[0..started]) |*future| future.await(test_io);
started = 0;
try awaitRetired(&h.controller, 400);
h.controller.shutdown(test_io);
const total = producers * each;
const reading = h.controller.sample(test_io);
// Every entry produced is accounted for exactly once, and none of them was
// lost to the swap.
try testing.expectEqual(@as(u64, total), reading.rows_written + reading.queries_dropped);
try testing.expectEqual(@as(u64, 0), reading.queries_dropped);
try testing.expectEqual(@as(i64, total), try h.storedRows());
}
test "a resize with the disk gate closed publishes at once and retires when the gate reopens" {
var monitor = critical();
var h: Harness = .{ .monitor = &monitor };
try h.start(.{ .query_log_buffer_max = 64, .query_log_flush_interval_s = 0 });
defer h.stop();
produce(&h.controller, 8, 1);
try awaitGated(&h.controller, 400);
// The old writer is holding a batch against a shut gate. Publish must not
// wait for it: nothing here is allowed to block on the disk.
const prepared = try h.prepareResize(128);
h.controller.publish(test_io, prepared);
try testing.expect(h.controller.retirementPending(test_io));
produce(&h.controller, 8, 100);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
try awaitRetired(&h.controller, 1200);
h.controller.shutdown(test_io);
// The retirement mode does not set `draining`, so the held batch waited for
// the disk instead of being counted as lost.
const reading = h.controller.sample(test_io);
try testing.expectEqual(@as(u64, 0), reading.queries_dropped);
try testing.expectEqual(@as(u64, 16), reading.rows_written);
try testing.expectEqual(@as(i64, 16), try h.storedRows());
}
test "a candidate whose writer cannot prepare its statements leaves the running logger alone" {
var h: Harness = .{};
try h.start(.{ .query_log_buffer_max = 16, .query_log_flush_interval_s = 0 });
defer h.stop();
produce(&h.controller, 4, 1);
try awaitRows(&h.controller, 4, 400);
// The live writer already holds its prepared statements; a new connection
// would find no table to prepare against.
var side = try h.sideConnection();
try side.exec("ALTER TABLE query_log RENAME TO query_log_hidden");
try testing.expectError(error.WriterStatementsFailed, h.prepareResize(64));
// Untouched: same capacity, same generation, nothing retiring, and the
// counters carry on from where they were.
try testing.expectEqual(@as(u32, 16), h.controller.capacity(test_io));
try testing.expect(!h.controller.retirementPending(test_io));
try testing.expect(!h.controller.sample(test_io).writer_failed);
try side.exec("ALTER TABLE query_log_hidden RENAME TO query_log");
side.close();
// And the refusal left nothing behind: the next resize works.
const prepared = try h.prepareResize(64);
h.controller.publish(test_io, prepared);
try awaitRetired(&h.controller, 400);
try testing.expectEqual(@as(u32, 64), h.controller.capacity(test_io));
produce(&h.controller, 4, 100);
h.controller.shutdown(test_io);
try testing.expectEqual(@as(u64, 8), h.controller.sample(test_io).rows_written);
}
test "a prepared candidate the caller abandons closes everything it opened" {
var h: Harness = .{};
try h.start(.{ .query_log_buffer_max = 16, .query_log_flush_interval_s = 0 });
defer h.stop();
const prepared = try h.prepareResize(64);
h.controller.abandon(test_io, prepared);
try testing.expectEqual(@as(u32, 16), h.controller.capacity(test_io));
try testing.expect(!h.controller.retirementPending(test_io));
produce(&h.controller, 3, 1);
h.controller.shutdown(test_io);
try testing.expectEqual(@as(u64, 3), h.controller.sample(test_io).rows_written);
}
test "an invalid buffer size is refused with the message startup uses" {
var h: Harness = .{};
try h.start(.{ .query_log_buffer_max = 16 });
defer h.stop();
var buf: [64]u8 = undefined;
try testing.expectError(error.BufferTooSmall, h.prepareResize(0));
try testing.expectEqualStrings(
"must be at least 1",
sizeMessage(error.BufferTooSmall, 0, &buf).?,
);
const over = logger.query_log_buffer_max + 1;
try testing.expectError(error.BufferTooLarge, h.prepareResize(over));
var expected_buf: [64]u8 = undefined;
// `config/validate.zig`'s wording, formatted from the same constant.
const expected = try std.fmt.bufPrint(
&expected_buf,
"must be at most {d}, got {d}",
.{ logger.query_log_buffer_max, over },
);
try testing.expectEqualStrings(expected, sizeMessage(error.BufferTooLarge, over, &buf).?);
// Neither refusal touched anything.
try testing.expectEqual(@as(u32, 16), h.controller.capacity(test_io));
}
test "a second resize is refused while the previous one is still draining" {
var monitor = critical();
var h: Harness = .{ .monitor = &monitor };
try h.start(.{ .query_log_buffer_max = 32, .query_log_flush_interval_s = 0 });
defer h.stop();
produce(&h.controller, 4, 1);
try awaitGated(&h.controller, 400);
const first = try h.prepareResize(64);
h.controller.publish(test_io, first);
// The gate is shut, so the retired writer cannot finish. A second resize
// would put a third buffer, connection and writer in flight.
try testing.expectError(error.PreviousResizeDraining, h.prepareResize(128));
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
try awaitRetired(&h.controller, 1200);
// The slot is free again the moment the retirement completes.
const second = try h.prepareResize(128);
h.controller.publish(test_io, second);
try testing.expectEqual(@as(u32, 128), h.controller.capacity(test_io));
try awaitRetired(&h.controller, 400);
h.controller.shutdown(test_io);
}
test "the counters, the drop stamp and the gate episode survive a swap" {
var monitor = critical();
var h: Harness = .{ .monitor = &monitor };
// One entry of room behind a writer the gate is holding: the producers
// below have to displace each other, so drops and a stamp exist to carry.
try h.start(.{ .query_log_buffer_max = 1, .query_log_flush_interval_s = 0 });
defer h.stop();
produce(&h.controller, 1, 1);
try awaitGated(&h.controller, 400);
produce(&h.controller, 40, 10);
const before = h.controller.sample(test_io);
try testing.expect(before.queries_dropped > 0);
try testing.expect(before.last_drop_s != null);
try testing.expectEqual(logger.GateEpisode.losing, before.gate_episode);
const prepared = try h.prepareResize(64);
h.controller.publish(test_io, prepared);
// Read across the swap: the new generation's counters start at zero, so
// anything that reported only the live generation would go backwards here.
const after = h.controller.sample(test_io);
try testing.expectEqual(before.queries_dropped, after.queries_dropped);
try testing.expectEqual(before.last_drop_s, after.last_drop_s);
try testing.expectEqual(before.rows_written, after.rows_written);
// The retired writer is still holding its batch against the shut gate, and
// that is still a current fault.
try testing.expectEqual(logger.GateEpisode.losing, after.gate_episode);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
try awaitRetired(&h.controller, 1200);
// Folding the retired generation's finals into the base is the other half:
// its counters must not vanish with it.
const settled = h.controller.sample(test_io);
try testing.expectEqual(before.queries_dropped, settled.queries_dropped);
try testing.expectEqual(before.last_drop_s, settled.last_drop_s);
try testing.expect(settled.rows_written >= before.rows_written);
try testing.expectEqual(logger.GateEpisode.open, settled.gate_episode);
h.controller.shutdown(test_io);
}
/// Flips both privacy fields together, over and over, through the controller.
fn flipPrivacy(controller: *Controller, rounds: usize) void {
for (0..rounds) |i| {
controller.setPrivacy(test_io, if (i % 2 == 0)
.{}
else
.{ .hide_domains = true, .hide_client_ips = true });
}
}
fn resizeRepeatedly(h: *Harness, rounds: usize) void {
for (0..rounds) |i| {
const prepared = h.prepareResize(if (i % 2 == 0) 48 else 64) catch continue;
h.controller.publish(test_io, prepared);
awaitRetired(&h.controller, 2000) catch return;
}
}
test "no producer sees a privacy policy that redacts one field and not the other, across resizes" {
var h: Harness = .{};
try h.start(.{ .query_log_buffer_max = 64, .query_log_flush_interval_s = 0 });
defer h.stop();
const source = testEntry(1, "tracker.example");
var flipper = try test_io.concurrent(flipPrivacy, .{ &h.controller, 20_000 });
var resizer = try test_io.concurrent(resizeRepeatedly, .{ &h, 4 });
// Recorded, not asserted, while the two other tasks run: an assertion that
// returned here would leave them unjoined.
var mixed = false;
for (0..20_000) |_| {
const generation = h.controller.acquire(test_io);
const out = generation.logger.transformed(source);
h.controller.release(test_io, generation);
const domain_hidden = std.mem.eql(u8, out.domain(), logger.hidden_marker);
const client_hidden = std.mem.eql(u8, out.clientIp(), logger.hidden_marker);
if (domain_hidden != client_hidden) mixed = true;
}
flipper.await(test_io);
resizer.await(test_io);
try testing.expect(!mixed);
try awaitRetired(&h.controller, 2000);
h.controller.shutdown(test_io);
}
test "shutdown joins a retirement the disk gate is still blocking" {
var monitor = critical();
var h: Harness = .{ .monitor = &monitor };
try h.start(.{ .query_log_buffer_max = 64, .query_log_flush_interval_s = 0 });
defer h.stop();
const held = 6;
produce(&h.controller, held, 1);
try awaitGated(&h.controller, 400);
const prepared = try h.prepareResize(128);
h.controller.publish(test_io, prepared);
try testing.expect(h.controller.retirementPending(test_io));
const after_swap = 4;
produce(&h.controller, after_swap, 100);
// The disk never recovers. Both writers are gate-held, one of them retired
// and owned by the reaper — the exit still ends, each future is awaited by
// exactly one task, and every entry is counted rather than silently lost.
h.controller.shutdown(test_io);
const reading = h.controller.sample(test_io);
try testing.expectEqual(@as(u64, held + after_swap), reading.queries_dropped);
try testing.expectEqual(@as(u64, 0), reading.rows_written);
try testing.expectEqual(@as(i64, 0), try h.storedRows());
}
test "a borrowed controller reports its logger and refuses to resize" {
var queue_buf: [4]logger.Entry = undefined;
var lg: logger.Logger = .init(.{}, &queue_buf);
lg.rows_written.store(7, .monotonic);
lg.queries_dropped.store(2, .monotonic);
var owner: Borrowed = .{};
const controller = owner.over(&lg);
const reading = controller.sample(test_io);
try testing.expectEqual(@as(u64, 7), reading.rows_written);
try testing.expectEqual(@as(u64, 2), reading.queries_dropped);
try testing.expectEqual(@as(u32, 4), controller.capacity(test_io));
try testing.expectError(error.NotResizable, controller.prepare(test_io, .{ .query_log_buffer_max = 8 }));
// Owns nothing, so both teardown calls are no-ops over the caller's logger.
controller.shutdown(test_io);
controller.deinit(test_io);
}