635 lines
27 KiB
Zig
635 lines
27 KiB
Zig
//! Per-minute upstream outcome history (milestone-26 rulings 1, 3, 4).
|
|
//!
|
|
//! Outcomes are aggregated into their wall-clock UTC minute at the moment the
|
|
//! pool records them, and the aggregates are flushed to `querylog.db` once a
|
|
//! minute. **Nothing samples a lifetime counter and subtracts.** That is what
|
|
//! makes the stored numbers additive facts: a restart inside a minute adds to
|
|
//! the same row, a crash loses at most the cells that had not flushed yet, and
|
|
//! no path anywhere can produce a negative delta.
|
|
//!
|
|
//! The query path may not touch SQLite, so recording is memory-only under this
|
|
//! module's own mutex and the writing happens on a task of its own.
|
|
//!
|
|
//! Two failure modes, deliberately kept apart:
|
|
//!
|
|
//! * **Overflow.** More live `(url, minute)` pairs than `max_pending`. The
|
|
//! oldest minute is dropped, `rows_dropped` counts it and
|
|
//! `last_drop_minute` remembers how new the newest lost minute was, so a
|
|
//! window that starts after it can still be reported as complete.
|
|
//! * **A failed flush.** Nothing is dropped: the rows go back into the
|
|
//! accumulator and the next pass writes them again.
|
|
//!
|
|
//! The wall clock, not `.awake`: history participates in wall-clock periods, so
|
|
//! a minute here is the same minute the dashboard's period picker means.
|
|
//! Routing state (`health.zig`) stays on `.awake` and is untouched by this
|
|
//! file.
|
|
|
|
const std = @import("std");
|
|
|
|
const db = @import("../storage/db.zig");
|
|
const health = @import("health.zig");
|
|
const upstream_history_repo = @import("../storage/repositories/upstream_history_repo.zig");
|
|
|
|
const log = std.log.scoped(.upstream_history);
|
|
|
|
/// Live `(url, minute)` cells. Not a knob (m26 anti-requirements). A household
|
|
/// pool is a handful of upstreams, so the bound is only reachable when flushing
|
|
/// has been failing for hours.
|
|
pub const max_pending = 4096;
|
|
|
|
/// One flush pass per minute, so an unflushed cell is at most about a minute
|
|
/// old. `.boot` rather than `.awake`, for `retention.zig`'s reason: a box that
|
|
/// suspends must still see its interval elapse.
|
|
pub const flush_interval_s = 60;
|
|
|
|
/// The UTC minute `wall_s` falls in, as its start in seconds. `@divFloor`, not
|
|
/// `@divTrunc`: a negative second belongs to the minute before it.
|
|
pub fn minuteOf(wall_s: i64) i64 {
|
|
return @divFloor(wall_s, 60) * 60;
|
|
}
|
|
|
|
/// The write seam. Production passes `upstream_history_repo.flush`; a test
|
|
/// passes a stub that fails, or one that records what it was handed.
|
|
pub const WriteFn = *const fn (*db.Db, []const upstream_history_repo.FlushRow) db.Error!void;
|
|
|
|
pub const Accumulator = struct {
|
|
pub const Cell = struct {
|
|
/// Borrowed from the pool entry's endpoint, which lives as long as the
|
|
/// process. Nothing here copies it, and nothing here may outlive it.
|
|
url: []const u8,
|
|
minute_ts: i64,
|
|
/// Both counters saturate instead of wrapping: every write site uses
|
|
/// `+|=` — `recordSuccess`, `recordFailure`, and `mergeBack`, which
|
|
/// sums a failed flush's copy back into the live cell. The saturation
|
|
/// is deliberate and unreachable: a cell counts one upstream's
|
|
/// outcomes inside a single wall-clock minute, so filling a `u32`
|
|
/// would take about 72 million exchanges per second with that one
|
|
/// upstream. Nothing reports it, by design — `last_drop_minute` and
|
|
/// the `complete` flag it feeds describe capacity drops, and a
|
|
/// saturated counter is not a drop.
|
|
successes: u32,
|
|
failures: u32,
|
|
last_failure_ts: ?i64,
|
|
last_error_buf: [health.error_name_capacity]u8,
|
|
last_error_len: u8,
|
|
|
|
fn lastError(self: *const Cell) []const u8 {
|
|
return self.last_error_buf[0..self.last_error_len];
|
|
}
|
|
};
|
|
|
|
/// A consistent copy for `/metrics`, `/api/health` and the API layer.
|
|
pub const Stats = struct {
|
|
flushes: u64 = 0,
|
|
flush_failures: u64 = 0,
|
|
rows_dropped: u64 = 0,
|
|
pending: u32 = 0,
|
|
/// The newest minute capacity has ever cost this process, or null when
|
|
/// nothing was ever dropped. A window that starts after it is complete
|
|
/// again, so one historical overflow does not mark every later answer.
|
|
last_drop_minute: ?i64 = null,
|
|
/// Current state, not a count: set by a failed flush and cleared by the
|
|
/// next successful one. Feeds the `/api/health` rollup.
|
|
last_flush_failed: bool = false,
|
|
};
|
|
|
|
/// Atomic for the reason `retention.zig`'s are: the flush task writes them
|
|
/// and the web task reads them, on different threads. They are bumped
|
|
/// outside the mutex, so the lock is not what orders them.
|
|
const Counters = struct {
|
|
flushes: std.atomic.Value(u64) = .init(0),
|
|
flush_failures: std.atomic.Value(u64) = .init(0),
|
|
rows_dropped: std.atomic.Value(u64) = .init(0),
|
|
};
|
|
|
|
mutex: std.Io.Mutex = .init,
|
|
cells: [max_pending]Cell,
|
|
count: u32,
|
|
last_drop_minute: ?i64,
|
|
last_flush_failed: bool,
|
|
counters: Counters,
|
|
/// Owned by whichever task runs `flushOnce`, which is one task. It is a
|
|
/// field rather than a local so that the megabyte it costs lives wherever
|
|
/// the accumulator was placed instead of on a task's stack.
|
|
flush_cells: [max_pending]Cell,
|
|
flush_rows: [max_pending]upstream_history_repo.FlushRow,
|
|
flush_count: u32,
|
|
|
|
/// `cells` and `flush_cells` are `undefined`: a cell is always written
|
|
/// before it is read, and `count` is what says which ones exist.
|
|
pub const init: Accumulator = .{
|
|
.mutex = .init,
|
|
.cells = undefined,
|
|
.count = 0,
|
|
.last_drop_minute = null,
|
|
.last_flush_failed = false,
|
|
.counters = .{},
|
|
.flush_cells = undefined,
|
|
.flush_rows = undefined,
|
|
.flush_count = 0,
|
|
};
|
|
|
|
pub fn recordSuccess(self: *Accumulator, io: std.Io, url: []const u8, wall_s: i64) void {
|
|
// Uncancelable for the reason the pool's health sections are: this
|
|
// takes no Io and never blocks on a peer, and losing the record of a
|
|
// completed exchange to a cancellation would undercount for good.
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
const cell = self.cellFor(url, minuteOf(wall_s));
|
|
cell.successes +|= 1;
|
|
}
|
|
|
|
pub fn recordFailure(
|
|
self: *Accumulator,
|
|
io: std.Io,
|
|
url: []const u8,
|
|
wall_s: i64,
|
|
error_name: []const u8,
|
|
) void {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
const cell = self.cellFor(url, minuteOf(wall_s));
|
|
cell.failures +|= 1;
|
|
noteFailure(cell, wall_s, error_name);
|
|
}
|
|
|
|
/// The only read surface. Every field above is private to this module, so
|
|
/// no consumer can read one of them without the mutex.
|
|
pub fn snapshotStats(self: *Accumulator, io: std.Io) Stats {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
return .{
|
|
.flushes = self.counters.flushes.load(.monotonic),
|
|
.flush_failures = self.counters.flush_failures.load(.monotonic),
|
|
.rows_dropped = self.counters.rows_dropped.load(.monotonic),
|
|
.pending = self.count,
|
|
.last_drop_minute = self.last_drop_minute,
|
|
.last_flush_failed = self.last_flush_failed,
|
|
};
|
|
}
|
|
|
|
/// The cell for `(url, minute_ts)`, created if it is not there yet. The
|
|
/// caller holds the mutex.
|
|
///
|
|
/// Linear: the live set is one cell per upstream per unflushed minute,
|
|
/// which at household scale is a handful. A miss at capacity evicts the
|
|
/// oldest minute — see `evictOldest`.
|
|
fn cellFor(self: *Accumulator, url: []const u8, minute_ts: i64) *Cell {
|
|
for (self.cells[0..self.count]) |*cell| {
|
|
if (cell.minute_ts != minute_ts) continue;
|
|
// Pointer equality first: every call from the pool passes the same
|
|
// `Entry.endpoint.url`, so the byte compare is the cold path.
|
|
if (cell.url.ptr == url.ptr and cell.url.len == url.len) return cell;
|
|
if (std.mem.eql(u8, cell.url, url)) return cell;
|
|
}
|
|
|
|
const slot = if (self.count < max_pending) fresh: {
|
|
const index = self.count;
|
|
self.count += 1;
|
|
break :fresh &self.cells[index];
|
|
} else self.evictOldest();
|
|
|
|
slot.* = .{
|
|
.url = url,
|
|
.minute_ts = minute_ts,
|
|
.successes = 0,
|
|
.failures = 0,
|
|
.last_failure_ts = null,
|
|
.last_error_buf = @splat(0),
|
|
.last_error_len = 0,
|
|
};
|
|
return slot;
|
|
}
|
|
|
|
/// Frees the cell holding the oldest minute and accounts for what it cost.
|
|
///
|
|
/// `last_drop_minute` moves through `@max` and never through assignment: a
|
|
/// merge-back after a failed flush can evict a cell older than one already
|
|
/// dropped, and a watermark that moved backwards would report a window as
|
|
/// complete when outcomes inside it are gone.
|
|
fn evictOldest(self: *Accumulator) *Cell {
|
|
var oldest: usize = 0;
|
|
for (self.cells[1..self.count], 1..) |*cell, i| {
|
|
if (cell.minute_ts < self.cells[oldest].minute_ts) oldest = i;
|
|
}
|
|
const evicted = self.cells[oldest].minute_ts;
|
|
self.last_drop_minute = @max(self.last_drop_minute orelse evicted, evicted);
|
|
_ = self.counters.rows_dropped.fetchAdd(1, .monotonic);
|
|
return &self.cells[oldest];
|
|
}
|
|
|
|
/// One flush pass: swap the dirty cells out, write them, and on failure put
|
|
/// them back.
|
|
///
|
|
/// **A swap, never a subtraction.** SQLite runs outside the mutex, so while
|
|
/// it does, a full accumulator can evict a cell that was copied out and
|
|
/// then recreate the same `(url, minute_ts)`. A post-flush subtract would
|
|
/// then destroy outcomes recorded during the write. Moving the cells out
|
|
/// makes the flush own them: what is recorded beside it is new data, and a
|
|
/// failure merges the two additively.
|
|
///
|
|
/// Every failure is counted and warned about once; nothing here returns an
|
|
/// error, because there is no caller that could do anything the next pass
|
|
/// will not do anyway.
|
|
pub fn flushOnce(self: *Accumulator, io: std.Io, database: *db.Db, write: WriteFn) void {
|
|
{
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
if (self.count == 0) return;
|
|
@memcpy(self.flush_cells[0..self.count], self.cells[0..self.count]);
|
|
self.flush_count = self.count;
|
|
self.count = 0;
|
|
}
|
|
|
|
const rows = self.flush_rows[0..self.flush_count];
|
|
for (self.flush_cells[0..self.flush_count], rows) |*cell, *row| {
|
|
row.* = .{
|
|
.url = cell.url,
|
|
.minute_ts = cell.minute_ts,
|
|
.successes = cell.successes,
|
|
.failures = cell.failures,
|
|
.last_failure_ts = cell.last_failure_ts,
|
|
.last_error = cell.lastError(),
|
|
};
|
|
}
|
|
|
|
if (write(database, rows)) {
|
|
_ = self.counters.flushes.fetchAdd(1, .monotonic);
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
self.last_flush_failed = false;
|
|
return;
|
|
} else |err| {
|
|
_ = self.counters.flush_failures.fetchAdd(1, .monotonic);
|
|
log.warn("flushing {d} upstream history rows failed: {s}", .{ rows.len, @errorName(err) });
|
|
self.mergeBack(io);
|
|
}
|
|
}
|
|
|
|
/// Puts a failed pass's cells back through the rules recording uses: a cell
|
|
/// recorded during the write keeps its outcomes and the merge sums into it,
|
|
/// and a merge that overflows follows the ordinary drop policy.
|
|
fn mergeBack(self: *Accumulator, io: std.Io) void {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
self.last_flush_failed = true;
|
|
|
|
for (self.flush_cells[0..self.flush_count]) |*saved| {
|
|
const cell = self.cellFor(saved.url, saved.minute_ts);
|
|
cell.successes +|= saved.successes;
|
|
cell.failures +|= saved.failures;
|
|
if (saved.last_failure_ts) |at| noteFailure(cell, at, saved.lastError());
|
|
}
|
|
}
|
|
|
|
/// Daily-loop shape (`retention.zig`): flush first, then sleep, so a
|
|
/// process that is about to be canceled has already written once.
|
|
pub fn run(self: *Accumulator, io: std.Io, database: *db.Db) std.Io.Cancelable!void {
|
|
const interval: std.Io.Clock.Duration = .{
|
|
.raw = .fromSeconds(flush_interval_s),
|
|
.clock = .boot,
|
|
};
|
|
while (true) {
|
|
self.flushOnce(io, database, upstream_history_repo.flush);
|
|
try interval.sleep(io);
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Max-wins, matching the SQL upsert exactly: the newest failure in the minute
|
|
/// is the one whose name the cell keeps.
|
|
fn noteFailure(cell: *Accumulator.Cell, at: i64, error_name: []const u8) void {
|
|
if (cell.last_failure_ts) |existing| {
|
|
if (at < existing) return;
|
|
}
|
|
cell.last_failure_ts = at;
|
|
const copied = @min(error_name.len, cell.last_error_buf.len);
|
|
@memcpy(cell.last_error_buf[0..copied], error_name[0..copied]);
|
|
cell.last_error_len = @intCast(copied);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const querylog_schema = @import("../storage/querylog_schema.zig");
|
|
|
|
const testing = std.testing;
|
|
|
|
fn openLog() !db.Db {
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
try database.exec(querylog_schema.ddl);
|
|
return database;
|
|
}
|
|
|
|
/// The accumulator is about a megabyte, which is more than a test frame should
|
|
/// carry.
|
|
fn newAccumulator() !*Accumulator {
|
|
const acc = try testing.allocator.create(Accumulator);
|
|
acc.* = .init;
|
|
return acc;
|
|
}
|
|
|
|
fn failingWrite(_: *db.Db, _: []const upstream_history_repo.FlushRow) db.Error!void {
|
|
return error.Busy;
|
|
}
|
|
|
|
/// What the last stubbed flush was handed, copied out so an assertion can read
|
|
/// it after the pass returned.
|
|
var recorded: [8]upstream_history_repo.FlushRow = undefined;
|
|
var recorded_len: usize = 0;
|
|
|
|
fn recordingWrite(_: *db.Db, rows: []const upstream_history_repo.FlushRow) db.Error!void {
|
|
recorded_len = @min(rows.len, recorded.len);
|
|
@memcpy(recorded[0..recorded_len], rows[0..recorded_len]);
|
|
}
|
|
|
|
/// The interleaving of ruling 4: a flush is in flight, and the recording side
|
|
/// recreates a swapped-out cell and then fills the accumulator to overflow.
|
|
var interleaved: ?*Accumulator = null;
|
|
var interleave_io: ?std.Io = null;
|
|
var interleave_urls: [max_pending][8]u8 = undefined;
|
|
|
|
fn interleavingWrite(_: *db.Db, _: []const upstream_history_repo.FlushRow) db.Error!void {
|
|
const acc = interleaved.?;
|
|
const io = interleave_io.?;
|
|
// The very `(url, minute_ts)` the flush is holding, recorded again while
|
|
// the write runs.
|
|
acc.recordSuccess(io, "https://a.example", 60);
|
|
// And then enough distinct minutes to fill the accumulator and evict.
|
|
for (&interleave_urls, 0..) |*name, i| {
|
|
const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
|
|
acc.recordSuccess(io, url, @as(i64, @intCast(i)) * 600 + 6000);
|
|
}
|
|
return error.Busy;
|
|
}
|
|
|
|
test "minuteOf floors to the minute, including before the epoch" {
|
|
try testing.expectEqual(@as(i64, 0), minuteOf(0));
|
|
try testing.expectEqual(@as(i64, 0), minuteOf(59));
|
|
try testing.expectEqual(@as(i64, 60), minuteOf(60));
|
|
try testing.expectEqual(@as(i64, 120), minuteOf(179));
|
|
try testing.expectEqual(@as(i64, -60), minuteOf(-1));
|
|
}
|
|
|
|
test "outcomes land in the cell of their own minute and upstream" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
acc.recordSuccess(io, "https://a.example", 65);
|
|
acc.recordSuccess(io, "https://a.example", 119);
|
|
acc.recordFailure(io, "https://a.example", 100, "Timeout");
|
|
acc.recordSuccess(io, "https://a.example", 130);
|
|
acc.recordSuccess(io, "https://b.example", 70);
|
|
|
|
// Three cells: a/60, a/120 and b/60.
|
|
try testing.expectEqual(@as(u32, 3), acc.snapshotStats(io).pending);
|
|
|
|
const first = acc.cellFor("https://a.example", 60);
|
|
try testing.expectEqual(@as(u32, 2), first.successes);
|
|
try testing.expectEqual(@as(u32, 1), first.failures);
|
|
try testing.expectEqual(@as(?i64, 100), first.last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", first.lastError());
|
|
|
|
const second = acc.cellFor("https://a.example", 120);
|
|
try testing.expectEqual(@as(u32, 1), second.successes);
|
|
try testing.expectEqual(@as(u32, 0), second.failures);
|
|
try testing.expectEqual(@as(?i64, null), second.last_failure_ts);
|
|
}
|
|
|
|
test "a cell keeps the newest failure's error and ignores an older one" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
acc.recordFailure(io, "https://a.example", 100, "Timeout");
|
|
acc.recordFailure(io, "https://a.example", 80, "ConnectFailed");
|
|
const cell = acc.cellFor("https://a.example", 60);
|
|
try testing.expectEqual(@as(u32, 2), cell.failures);
|
|
try testing.expectEqual(@as(?i64, 100), cell.last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", cell.lastError());
|
|
|
|
acc.recordFailure(io, "https://a.example", 110, "BadResponse");
|
|
try testing.expectEqual(@as(?i64, 110), cell.last_failure_ts);
|
|
try testing.expectEqualStrings("BadResponse", cell.lastError());
|
|
|
|
const long = "A" ** 200;
|
|
acc.recordFailure(io, "https://a.example", 115, long);
|
|
try testing.expectEqual(@as(usize, health.error_name_capacity), cell.lastError().len);
|
|
}
|
|
|
|
test "at capacity the oldest minute is dropped, counted, and the watermark only moves forward" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
var names: [max_pending][8]u8 = undefined;
|
|
for (&names, 0..) |*name, i| {
|
|
const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
|
|
// Minute 600 is the oldest; every later cell is newer.
|
|
acc.recordSuccess(io, url, 600 + @as(i64, @intCast(i)) * 60);
|
|
}
|
|
try testing.expectEqual(@as(u32, max_pending), acc.snapshotStats(io).pending);
|
|
try testing.expectEqual(@as(u64, 0), acc.snapshotStats(io).rows_dropped);
|
|
try testing.expectEqual(@as(?i64, null), acc.snapshotStats(io).last_drop_minute);
|
|
|
|
// One more cell evicts the oldest minute and nothing else.
|
|
acc.recordSuccess(io, "https://new.example", 10_000_000);
|
|
const after = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u32, max_pending), after.pending);
|
|
try testing.expectEqual(@as(u64, 1), after.rows_dropped);
|
|
try testing.expectEqual(@as(?i64, 600), after.last_drop_minute);
|
|
|
|
// A later eviction of an *older* minute must not move the watermark back.
|
|
acc.recordSuccess(io, "https://older.example", 120);
|
|
const back = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u64, 2), back.rows_dropped);
|
|
try testing.expectEqual(@as(?i64, 660), back.last_drop_minute);
|
|
|
|
acc.recordSuccess(io, "https://newer.example", 20_000_000);
|
|
const forward = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u64, 3), forward.rows_dropped);
|
|
// The minute just evicted is 120, older than the 660 already recorded.
|
|
try testing.expectEqual(@as(?i64, 660), forward.last_drop_minute);
|
|
}
|
|
|
|
test "a successful flush hands over every cell, empties the accumulator and counts once" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
acc.recordSuccess(io, "https://a.example", 65);
|
|
acc.recordFailure(io, "https://a.example", 100, "Timeout");
|
|
acc.flushOnce(io, &database, recordingWrite);
|
|
|
|
try testing.expectEqual(@as(usize, 1), recorded_len);
|
|
try testing.expectEqualStrings("https://a.example", recorded[0].url);
|
|
try testing.expectEqual(@as(i64, 60), recorded[0].minute_ts);
|
|
try testing.expectEqual(@as(u32, 1), recorded[0].successes);
|
|
try testing.expectEqual(@as(u32, 1), recorded[0].failures);
|
|
try testing.expectEqual(@as(?i64, 100), recorded[0].last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", recorded[0].last_error);
|
|
|
|
const stats = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u32, 0), stats.pending);
|
|
try testing.expectEqual(@as(u64, 1), stats.flushes);
|
|
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
|
|
try testing.expect(!stats.last_flush_failed);
|
|
}
|
|
|
|
test "a pass with nothing pending counts neither a flush nor a failure" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
acc.flushOnce(io, &database, failingWrite);
|
|
acc.flushOnce(io, &database, recordingWrite);
|
|
|
|
const stats = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u64, 0), stats.flushes);
|
|
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
|
|
try testing.expect(!stats.last_flush_failed);
|
|
}
|
|
|
|
test "a failed flush keeps the rows, sets the flag, and the next success clears it" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
acc.recordSuccess(io, "https://a.example", 65);
|
|
acc.recordFailure(io, "https://a.example", 100, "Timeout");
|
|
acc.flushOnce(io, &database, failingWrite);
|
|
|
|
const failed = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u64, 0), failed.flushes);
|
|
try testing.expectEqual(@as(u64, 1), failed.flush_failures);
|
|
try testing.expectEqual(@as(u64, 0), failed.rows_dropped);
|
|
try testing.expect(failed.last_flush_failed);
|
|
// Nothing was lost: the cell is back, whole.
|
|
try testing.expectEqual(@as(u32, 1), failed.pending);
|
|
const cell = acc.cellFor("https://a.example", 60);
|
|
try testing.expectEqual(@as(u32, 1), cell.successes);
|
|
try testing.expectEqual(@as(u32, 1), cell.failures);
|
|
try testing.expectEqualStrings("Timeout", cell.lastError());
|
|
|
|
// The retry writes what the failed pass could not.
|
|
acc.recordSuccess(io, "https://a.example", 70);
|
|
acc.flushOnce(io, &database, recordingWrite);
|
|
const cleared = acc.snapshotStats(io);
|
|
try testing.expectEqual(@as(u64, 1), cleared.flushes);
|
|
try testing.expectEqual(@as(u64, 1), cleared.flush_failures);
|
|
try testing.expect(!cleared.last_flush_failed);
|
|
try testing.expectEqual(@as(usize, 1), recorded_len);
|
|
try testing.expectEqual(@as(u32, 2), recorded[0].successes);
|
|
try testing.expectEqual(@as(u32, 1), recorded[0].failures);
|
|
}
|
|
|
|
test "a merge-back sums into what was recorded beside the flush" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
// The write recreates the swapped-out cell before it fails, which is the
|
|
// interleaving the swap makes possible.
|
|
interleaved = acc;
|
|
interleave_io = io;
|
|
defer {
|
|
interleaved = null;
|
|
interleave_io = null;
|
|
}
|
|
|
|
acc.recordSuccess(io, "https://a.example", 65);
|
|
acc.recordFailure(io, "https://a.example", 100, "Timeout");
|
|
acc.flushOnce(io, &database, interleavingWrite);
|
|
|
|
const stats = acc.snapshotStats(io);
|
|
try testing.expect(stats.last_flush_failed);
|
|
try testing.expectEqual(@as(u64, 1), stats.flush_failures);
|
|
// Two drops, and they are different drops: filling to capacity during the
|
|
// write evicted the recreated minute 60, and the merge-back then found no
|
|
// room either and evicted the oldest of what the write had left, 6000.
|
|
// Overflow loss is the specced policy; what matters is that it is counted.
|
|
try testing.expectEqual(@as(u32, max_pending), stats.pending);
|
|
try testing.expectEqual(@as(u64, 2), stats.rows_dropped);
|
|
// Forward only: the second eviction was the newer minute of the two.
|
|
try testing.expectEqual(@as(?i64, 6000), stats.last_drop_minute);
|
|
// The merged cell is back, carrying what the failed flush was holding.
|
|
const merged = acc.cellFor("https://a.example", 60);
|
|
try testing.expectEqual(@as(u32, 1), merged.successes);
|
|
try testing.expectEqual(@as(u32, 1), merged.failures);
|
|
try testing.expectEqualStrings("Timeout", merged.lastError());
|
|
|
|
// The flush-owned buffer was not touched by any of the recording that
|
|
// happened beside it: it still holds exactly what was swapped out.
|
|
try testing.expectEqual(@as(u32, 1), acc.flush_count);
|
|
try testing.expectEqual(@as(u32, 1), acc.flush_cells[0].successes);
|
|
try testing.expectEqual(@as(u32, 1), acc.flush_cells[0].failures);
|
|
try testing.expectEqual(@as(i64, 60), acc.flush_cells[0].minute_ts);
|
|
}
|
|
|
|
test "a flush against the real repository writes the minute rows" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
const acc = try newAccumulator();
|
|
defer testing.allocator.destroy(acc);
|
|
|
|
acc.recordSuccess(io, "https://a.example", 65);
|
|
acc.recordFailure(io, "https://a.example", 100, "Timeout");
|
|
acc.recordSuccess(io, "https://b.example", 130);
|
|
acc.flushOnce(io, &database, upstream_history_repo.flush);
|
|
|
|
// The second pass is the restart shape: the same minute, added to.
|
|
acc.recordSuccess(io, "https://a.example", 90);
|
|
acc.flushOnce(io, &database, upstream_history_repo.flush);
|
|
|
|
const stats = try upstream_history_repo.windowStats(&database, "https://a.example", 0, 200);
|
|
try testing.expectEqual(@as(u64, 2), stats.successes);
|
|
try testing.expectEqual(@as(u64, 1), stats.failures);
|
|
try testing.expectEqual(@as(u64, 3), stats.attempts);
|
|
try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", stats.lastFailureError());
|
|
try testing.expectEqual(@as(i64, 2), try upstream_history_repo.countMinutes(&database));
|
|
try testing.expectEqual(@as(u64, 2), acc.snapshotStats(io).flushes);
|
|
}
|