milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
This commit is contained in:
@@ -23,6 +23,7 @@ const std = @import("std");
|
||||
|
||||
const db = @import("db.zig");
|
||||
const disk_monitor = @import("disk_monitor.zig");
|
||||
const events = @import("events.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const queries_repo = @import("repositories/queries_repo.zig");
|
||||
|
||||
@@ -177,6 +178,9 @@ 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),
|
||||
/// Wired by the composition root after `init`, following the
|
||||
/// `gate: ?*disk_monitor.Monitor` idiom. Null in every unit test here.
|
||||
diagnostics: ?*events.Store = null,
|
||||
|
||||
/// `queue_buf.len` is the backpressure cap — the composition root
|
||||
/// (`app.zig:311`) allocates `cfg.logging.query_log_buffer_max` entries,
|
||||
@@ -254,6 +258,9 @@ pub const Logger = struct {
|
||||
// Without a writer there is no consumer, so leaving the queue open
|
||||
// would silently swallow every later entry.
|
||||
self.writer_failed.store(true, .release);
|
||||
// No recovery path claims this episode: the writer is gone for the
|
||||
// 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);
|
||||
return;
|
||||
@@ -400,9 +407,41 @@ pub const Logger = struct {
|
||||
writer.writeBatch(rows[0..entries.len]) catch |err| {
|
||||
scope.warn("query log batch of {d} rows dropped: {s}", .{ entries.len, @errorName(err) });
|
||||
self.countDropped(entries.len);
|
||||
self.reportWrite(io, "batch", "a query log batch was dropped", @errorName(err), entries.len);
|
||||
return;
|
||||
};
|
||||
_ = self.rows_written.fetchAdd(entries.len, .monotonic);
|
||||
if (self.diagnostics) |store| {
|
||||
store.resolve(io, std.Io.Clock.real.now(io).toSeconds(), .query_log_write, "batch");
|
||||
}
|
||||
}
|
||||
|
||||
/// An error, not a warning: dropped query rows are gone, and a writer that
|
||||
/// never started means every later row is gone too.
|
||||
fn reportWrite(
|
||||
self: *Logger,
|
||||
io: std.Io,
|
||||
operation: []const u8,
|
||||
message: []const u8,
|
||||
error_name: []const u8,
|
||||
rows: usize,
|
||||
) void {
|
||||
const store = self.diagnostics orelse return;
|
||||
var buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&buf, "{s}: {s} ({d} rows)", .{
|
||||
message,
|
||||
error_name,
|
||||
rows,
|
||||
}) catch buf[0..];
|
||||
store.report(
|
||||
io,
|
||||
std.Io.Clock.real.now(io).toSeconds(),
|
||||
.query_log_write,
|
||||
operation,
|
||||
operation,
|
||||
.@"error",
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
fn countDropped(self: *Logger, n: usize) void {
|
||||
@@ -441,6 +480,7 @@ fn outcomeEntry(outcome: Outcome) ?Entry {
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const events_fixture = @import("events_fixture.zig");
|
||||
const querylog_schema = @import("querylog_schema.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
@@ -881,3 +921,81 @@ test "an empty batch touches neither the database nor the counters" {
|
||||
try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));
|
||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
||||
}
|
||||
|
||||
test "a dropped batch opens an error episode the next good batch closes" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER refuse_boom BEFORE INSERT ON query_log
|
||||
\\WHEN new.client_ip = 'boom'
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
logger.diagnostics = &fx.store;
|
||||
|
||||
var doomed = sampleEntry(10, "poison.example");
|
||||
doomed.setClientIp("boom");
|
||||
const bad = [_]Entry{doomed};
|
||||
try logger.flush(io, &writer, &bad, null);
|
||||
|
||||
try testing.expectEqualStrings("query_log.write", try fx.text("SELECT code FROM operational_events"));
|
||||
try testing.expectEqualStrings("batch", try fx.text("SELECT subject_key FROM operational_events"));
|
||||
try testing.expectEqualStrings("error", try fx.text("SELECT severity FROM operational_events"));
|
||||
|
||||
const good = [_]Entry{sampleEntry(11, "next.example")};
|
||||
try logger.flush(io, &writer, &good, null);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a writer that cannot prepare leaves an episode no recovery path claims" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// No schema: `BatchWriter.init` cannot prepare against a missing table.
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var buf: [8]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
logger.diagnostics = &fx.store;
|
||||
|
||||
try logger.runWriter(io, &database, null);
|
||||
|
||||
try testing.expectEqualStrings("writer", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("error", try fx.text(
|
||||
"SELECT severity FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
|
||||
// The writer returned, so nothing can ever close this. A second run finds
|
||||
// the queue closed and adds no second episode.
|
||||
try logger.runWriter(io, &database, null);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user