494 lines
22 KiB
Zig
494 lines
22 KiB
Zig
//! `upstream_minute` and its `upstream_targets` dimension table in
|
|
//! `querylog.db` (milestone-26 rulings 2, 4, 5).
|
|
//!
|
|
//! One row per upstream per wall-clock UTC minute that had at least one
|
|
//! attempt. Rows are additive facts: a flush adds to whatever is already there,
|
|
//! so a restart inside a minute continues that minute's row rather than
|
|
//! replacing it, and nothing here can lower a stored count.
|
|
//!
|
|
//! Identity is the url, not the `config.db` upstream id: ids cannot be foreign
|
|
//! keys across database files and may be deleted or reused. Editing an
|
|
//! upstream's url deliberately starts a new history.
|
|
//!
|
|
//! Nothing here retries. The accumulator owns what a failed flush means
|
|
//! (`upstream/history.zig`).
|
|
|
|
const std = @import("std");
|
|
|
|
const db = @import("../db.zig");
|
|
const health = @import("../../upstream/health.zig");
|
|
|
|
/// How far back `Retention` keeps minute rows. A fixed window, not a knob
|
|
/// (m26 anti-requirements), and deliberately wider than the widest dashboard
|
|
/// period: `stats.window` derives `since = until - width * count` with
|
|
/// `until > now`, so a 30-day window never asks for anything older than
|
|
/// `now - 30d`. The extra day is slack for a retention pass that runs late.
|
|
///
|
|
/// `logging.retention_days` does not apply here. It bounds the query log, whose
|
|
/// rows are per-query; these are per-minute aggregates whose whole purpose is
|
|
/// to outlive them.
|
|
pub const retention_window_s: i64 = 31 * 86_400;
|
|
|
|
/// One minute of one upstream's outcomes, as the accumulator hands it over.
|
|
/// Every string is borrowed for the duration of the call: `Stmt.bindText` binds
|
|
/// with `SQLITE_TRANSIENT`, so SQLite copies before `flush` returns.
|
|
pub const FlushRow = struct {
|
|
url: []const u8,
|
|
minute_ts: i64,
|
|
successes: u32,
|
|
failures: u32,
|
|
last_failure_ts: ?i64,
|
|
/// Empty when the minute held no failure.
|
|
last_error: []const u8,
|
|
};
|
|
|
|
const insert_target_sql = "INSERT OR IGNORE INTO upstream_targets (url) VALUES (?1)";
|
|
|
|
const select_target_sql = "SELECT id FROM upstream_targets WHERE url = ?1";
|
|
|
|
/// Additive, and the timestamp columns are max-wins, which is what makes a
|
|
/// flush safe to repeat against a row another process already wrote.
|
|
///
|
|
/// `max()` over a NULL is NULL in SQLite, so the coalesce is what keeps an
|
|
/// existing `last_failure_ts` when the incoming row carries none. The `CASE`
|
|
/// moves `last_error` with the timestamp it belongs to: a success-only upsert
|
|
/// leaves the stored failure and its name exactly as they were.
|
|
const upsert_minute_sql =
|
|
\\INSERT INTO upstream_minute (upstream_id, minute_ts, successes, failures, last_failure_ts, last_error)
|
|
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
\\ON CONFLICT(upstream_id, minute_ts) DO UPDATE SET
|
|
\\ successes = successes + excluded.successes,
|
|
\\ failures = failures + excluded.failures,
|
|
\\ last_failure_ts = coalesce(max(last_failure_ts, excluded.last_failure_ts), last_failure_ts, excluded.last_failure_ts),
|
|
\\ last_error = CASE
|
|
\\ WHEN excluded.last_failure_ts IS NOT NULL
|
|
\\ AND (last_failure_ts IS NULL OR excluded.last_failure_ts >= last_failure_ts)
|
|
\\ THEN excluded.last_error
|
|
\\ ELSE last_error
|
|
\\ END
|
|
;
|
|
|
|
/// One transaction for the whole batch: either every minute of the pass lands
|
|
/// or none of it does, so a failed flush leaves nothing half-written for the
|
|
/// caller's merge-back to double-count.
|
|
pub fn flush(database: *db.Db, rows: []const FlushRow) db.Error!void {
|
|
if (rows.len == 0) return;
|
|
|
|
var insert_target = try database.prepare(insert_target_sql);
|
|
defer insert_target.deinit();
|
|
var select_target = try database.prepare(select_target_sql);
|
|
defer select_target.deinit();
|
|
var upsert = try database.prepare(upsert_minute_sql);
|
|
defer upsert.deinit();
|
|
|
|
var tx = try db.Tx.begin(database);
|
|
errdefer tx.rollback();
|
|
|
|
for (rows) |row| {
|
|
const upstream_id = try internTarget(&insert_target, &select_target, row.url);
|
|
|
|
try upsert.reset();
|
|
try upsert.bindInt(1, upstream_id);
|
|
try upsert.bindInt(2, row.minute_ts);
|
|
try upsert.bindInt(3, row.successes);
|
|
try upsert.bindInt(4, row.failures);
|
|
if (row.last_failure_ts) |at| try upsert.bindInt(5, at) else try upsert.bindNull(5);
|
|
try upsert.bindText(6, row.last_error);
|
|
try upsert.exec();
|
|
}
|
|
|
|
try tx.commit();
|
|
}
|
|
|
|
fn internTarget(insert: *db.Stmt, select: *db.Stmt, url: []const u8) db.Error!i64 {
|
|
try insert.reset();
|
|
try insert.bindText(1, url);
|
|
try insert.exec();
|
|
|
|
try select.reset();
|
|
try select.bindText(1, url);
|
|
// The insert above either created the row or found it already there, so a
|
|
// miss means the table changed under this connection.
|
|
if (!try select.step()) return error.NotFound;
|
|
const id = select.columnInt(0);
|
|
// A statement stopped on a row keeps its cursor open until it is reset;
|
|
// the transaction must not carry that to the next row.
|
|
try select.reset();
|
|
return id;
|
|
}
|
|
|
|
/// What `GET /api/upstream/health` reports for one upstream over one window.
|
|
pub const WindowStats = struct {
|
|
attempts: u64,
|
|
successes: u64,
|
|
failures: u64,
|
|
last_failure_ts: ?i64,
|
|
/// The error name of the row holding the newest `last_failure_ts` in the
|
|
/// window; empty when the window holds no failure.
|
|
///
|
|
/// By value rather than by slice: the caller loops over upstreams and
|
|
/// reuses one `WindowStats`, so a borrowed slice would dangle into the
|
|
/// storage the next iteration overwrites.
|
|
last_failure_error_buf: [health.error_name_capacity]u8,
|
|
last_failure_error_len: u8,
|
|
|
|
pub fn lastFailureError(self: *const WindowStats) []const u8 {
|
|
return self.last_failure_error_buf[0..self.last_failure_error_len];
|
|
}
|
|
};
|
|
|
|
/// **One statement, deliberately.** Two statements would not share a SQLite
|
|
/// snapshot: the flush connection can commit between them, and the read would
|
|
/// then pair a `max(last_failure_ts)` taken from one state with an error text
|
|
/// taken from another.
|
|
///
|
|
/// The error lookup is a scalar subquery for the same reason it is not a bare
|
|
/// column: `SELECT max(last_failure_ts), last_error` lets SQLite return the
|
|
/// `last_error` of an arbitrary row of the group. `ORDER BY ... DESC, minute_ts
|
|
/// DESC` makes the choice deterministic when two minutes share a timestamp.
|
|
const window_stats_sql =
|
|
\\SELECT coalesce(sum(m.successes), 0), coalesce(sum(m.failures), 0), max(m.last_failure_ts),
|
|
\\ (SELECT e.last_error FROM upstream_minute e
|
|
\\ WHERE e.upstream_id = m.upstream_id AND e.minute_ts >= ?2 AND e.minute_ts < ?3
|
|
\\ AND e.last_failure_ts IS NOT NULL
|
|
\\ ORDER BY e.last_failure_ts DESC, e.minute_ts DESC LIMIT 1)
|
|
\\ FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
|
|
\\ WHERE t.url = ?1 AND m.minute_ts >= ?2 AND m.minute_ts < ?3
|
|
;
|
|
|
|
/// Aggregates `[since, until)` by `minute_ts`. An unknown url or an empty
|
|
/// window is zeros, a null timestamp and the empty error — not an error.
|
|
pub fn windowStats(database: *db.Db, url: []const u8, since: i64, until: i64) db.Error!WindowStats {
|
|
var stmt = try database.prepare(window_stats_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, url);
|
|
try stmt.bindInt(2, since);
|
|
try stmt.bindInt(3, until);
|
|
|
|
// A bare aggregate always produces exactly one row; no row means the
|
|
// statement is not the one this function prepared.
|
|
if (!try stmt.step()) return error.Misuse;
|
|
|
|
const successes = try countOf(stmt.columnInt(0));
|
|
const failures = try countOf(stmt.columnInt(1));
|
|
|
|
var out: WindowStats = .{
|
|
.attempts = successes + failures,
|
|
.successes = successes,
|
|
.failures = failures,
|
|
.last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
|
|
.last_failure_error_buf = @splat(0),
|
|
.last_failure_error_len = 0,
|
|
};
|
|
const name = stmt.columnText(3);
|
|
const copied = @min(name.len, out.last_failure_error_buf.len);
|
|
@memcpy(out.last_failure_error_buf[0..copied], name[0..copied]);
|
|
out.last_failure_error_len = @intCast(copied);
|
|
return out;
|
|
}
|
|
|
|
/// `sum` over `CHECK (… >= 0)` columns cannot go negative; a negative value
|
|
/// means the row came from something other than this schema.
|
|
fn countOf(value: i64) db.Error!u64 {
|
|
if (value < 0) return error.Mismatch;
|
|
return @intCast(value);
|
|
}
|
|
|
|
/// Deletes every `upstream_minute` row strictly older than `cutoff_ts`, then
|
|
/// the targets no surviving row references, and returns how many **minute**
|
|
/// rows went.
|
|
///
|
|
/// One transaction: a target dropped without its rows, or rows dropped while
|
|
/// the target delete failed, would leave the foreign key pointing at nothing.
|
|
/// The count is minute rows only, so the metric an operator watches counts
|
|
/// aggregates rather than dimension-table housekeeping.
|
|
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
|
|
var tx = try db.Tx.begin(database);
|
|
errdefer tx.rollback();
|
|
|
|
var minutes = try database.prepare("DELETE FROM upstream_minute WHERE minute_ts < ?1");
|
|
defer minutes.deinit();
|
|
try minutes.bindInt(1, cutoff_ts);
|
|
try minutes.exec();
|
|
const deleted = database.changes();
|
|
|
|
try database.exec(
|
|
\\DELETE FROM upstream_targets
|
|
\\ WHERE id NOT IN (SELECT upstream_id FROM upstream_minute);
|
|
);
|
|
|
|
try tx.commit();
|
|
return deleted;
|
|
}
|
|
|
|
pub fn countMinutes(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT count(*) FROM upstream_minute");
|
|
}
|
|
|
|
pub fn countTargets(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT count(*) FROM upstream_targets");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const querylog_schema = @import("../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;
|
|
}
|
|
|
|
/// `columnText` is borrowed until the statement is finalized, so the stored
|
|
/// error name is copied out rather than returned as a slice.
|
|
const StoredMinute = struct {
|
|
successes: u32,
|
|
failures: u32,
|
|
last_failure_ts: ?i64,
|
|
error_buf: [health.error_name_capacity]u8,
|
|
error_len: u8,
|
|
|
|
fn lastError(self: *const StoredMinute) []const u8 {
|
|
return self.error_buf[0..self.error_len];
|
|
}
|
|
};
|
|
|
|
fn readMinute(database: *db.Db, url: []const u8, minute_ts: i64) !StoredMinute {
|
|
var stmt = try database.prepare(
|
|
\\SELECT m.successes, m.failures, m.last_failure_ts, m.last_error
|
|
\\ FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
|
|
\\ WHERE t.url = ?1 AND m.minute_ts = ?2
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, url);
|
|
try stmt.bindInt(2, minute_ts);
|
|
try testing.expect(try stmt.step());
|
|
var out: StoredMinute = .{
|
|
.successes = @intCast(stmt.columnInt(0)),
|
|
.failures = @intCast(stmt.columnInt(1)),
|
|
.last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
|
|
.error_buf = @splat(0),
|
|
.error_len = 0,
|
|
};
|
|
const name = stmt.columnText(3);
|
|
@memcpy(out.error_buf[0..name.len], name);
|
|
out.error_len = @intCast(name.len);
|
|
return out;
|
|
}
|
|
|
|
test "the upsert adds to the row already there rather than replacing it" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 3, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
|
|
});
|
|
// The shape a restart inside one minute takes: a second process writes the
|
|
// same (url, minute) and the counts continue.
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 4, .last_failure_ts = 90, .last_error = "ConnectFailed" },
|
|
});
|
|
|
|
const row = try readMinute(&database, "https://a.example", 60);
|
|
try testing.expectEqual(@as(u32, 5), row.successes);
|
|
try testing.expectEqual(@as(u32, 5), row.failures);
|
|
try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
|
|
try testing.expectEqualStrings("ConnectFailed", row.lastError());
|
|
// One row and one target, not two of either.
|
|
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
|
|
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
|
|
}
|
|
|
|
test "a success-only upsert keeps the failure timestamp and its error" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
|
|
});
|
|
// `max()` over a NULL is NULL in SQLite, so without the coalesce this
|
|
// upsert would erase the timestamp it knows nothing about.
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 5, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
});
|
|
|
|
const row = try readMinute(&database, "https://a.example", 60);
|
|
try testing.expectEqual(@as(u32, 5), row.successes);
|
|
try testing.expectEqual(@as(u32, 1), row.failures);
|
|
try testing.expectEqual(@as(?i64, 70), row.last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", row.lastError());
|
|
}
|
|
|
|
test "an older failure does not overwrite the newer error already stored" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 90, .last_error = "Timeout" },
|
|
});
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "ConnectFailed" },
|
|
});
|
|
|
|
const row = try readMinute(&database, "https://a.example", 60);
|
|
try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", row.lastError());
|
|
}
|
|
|
|
test "flush interns each url once and writes every minute of the batch" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
.{ .url = "https://a.example", .minute_ts = 120, .successes = 2, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
.{ .url = "https://b.example", .minute_ts = 60, .successes = 3, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
});
|
|
|
|
try testing.expectEqual(@as(i64, 3), try countMinutes(&database));
|
|
try testing.expectEqual(@as(i64, 2), try countTargets(&database));
|
|
|
|
// An empty batch opens no transaction: one is already open here, so a
|
|
// `BEGIN IMMEDIATE` would fail.
|
|
var tx = try db.Tx.begin(&database);
|
|
try flush(&database, &.{});
|
|
tx.rollback();
|
|
}
|
|
|
|
test "windowStats sums only the window and pairs the newest failure with its own error" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
try flush(&database, &.{
|
|
// Before the window.
|
|
.{ .url = "https://a.example", .minute_ts = 0, .successes = 9, .failures = 9, .last_failure_ts = 30, .last_error = "Outside" },
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 1, .last_failure_ts = 100, .last_error = "ConnectFailed" },
|
|
.{ .url = "https://a.example", .minute_ts = 120, .successes = 4, .failures = 2, .last_failure_ts = 170, .last_error = "Timeout" },
|
|
// A later minute with no failure at all: the error must still come from
|
|
// the minute holding the newest `last_failure_ts`, not from this one.
|
|
.{ .url = "https://a.example", .minute_ts = 180, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
// At the exclusive end of the window.
|
|
.{ .url = "https://a.example", .minute_ts = 240, .successes = 7, .failures = 7, .last_failure_ts = 250, .last_error = "After" },
|
|
// A different upstream in the same minutes.
|
|
.{ .url = "https://b.example", .minute_ts = 120, .successes = 5, .failures = 5, .last_failure_ts = 175, .last_error = "Other" },
|
|
});
|
|
|
|
const stats = try windowStats(&database, "https://a.example", 60, 240);
|
|
try testing.expectEqual(@as(u64, 7), stats.successes);
|
|
try testing.expectEqual(@as(u64, 3), stats.failures);
|
|
try testing.expectEqual(@as(u64, 10), stats.attempts);
|
|
try testing.expectEqual(@as(?i64, 170), stats.last_failure_ts);
|
|
try testing.expectEqualStrings("Timeout", stats.lastFailureError());
|
|
}
|
|
|
|
test "two minutes sharing the newest failure timestamp resolve to the later minute" {
|
|
// The tiebreak the subquery's ORDER BY owns. `max(last_failure_ts)` alone
|
|
// cannot choose between these two rows, so without a deterministic second
|
|
// key the answer is whichever row SQLite happened to visit.
|
|
var database = try openLog();
|
|
defer database.close();
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Earlier" },
|
|
.{ .url = "https://a.example", .minute_ts = 120, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Later" },
|
|
});
|
|
|
|
const stats = try windowStats(&database, "https://a.example", 0, 1000);
|
|
try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
|
|
try testing.expectEqualStrings("Later", stats.lastFailureError());
|
|
}
|
|
|
|
test "windowStats over an unknown url or an empty window is zeros and no error" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
|
|
});
|
|
|
|
for ([_][3]i64{ .{ 0, 60, 0 }, .{ 120, 180, 0 }, .{ 60, 60, 0 } }) |window| {
|
|
const stats = try windowStats(&database, "https://a.example", window[0], window[1]);
|
|
try testing.expectEqual(@as(u64, 0), stats.attempts);
|
|
try testing.expectEqual(@as(u64, 0), stats.successes);
|
|
try testing.expectEqual(@as(u64, 0), stats.failures);
|
|
try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
|
|
try testing.expectEqualStrings("", stats.lastFailureError());
|
|
}
|
|
|
|
const unknown = try windowStats(&database, "https://never.example", 0, 1000);
|
|
try testing.expectEqual(@as(u64, 0), unknown.attempts);
|
|
try testing.expectEqual(@as(?i64, null), unknown.last_failure_ts);
|
|
try testing.expectEqualStrings("", unknown.lastFailureError());
|
|
}
|
|
|
|
test "a window whose only failures are outside it reports no failure at all" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 0, .successes = 0, .failures = 1, .last_failure_ts = 30, .last_error = "Timeout" },
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 4, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
});
|
|
|
|
const stats = try windowStats(&database, "https://a.example", 60, 120);
|
|
try testing.expectEqual(@as(u64, 4), stats.attempts);
|
|
try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
|
|
try testing.expectEqualStrings("", stats.lastFailureError());
|
|
}
|
|
|
|
test "pruneOlderThan counts minute rows only and drops the orphaned target" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
|
|
try flush(&database, &.{
|
|
.{ .url = "https://old.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
.{ .url = "https://old.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
.{ .url = "https://kept.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
.{ .url = "https://kept.example", .minute_ts = 300, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
});
|
|
|
|
// Three minute rows go; the two target deletes must not join the count.
|
|
try testing.expectEqual(@as(i64, 3), try pruneOlderThan(&database, 300));
|
|
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
|
|
// "old.example" has nothing left, "kept.example" still does.
|
|
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
|
|
|
|
// The row exactly at the cutoff stays, and a second pass finds nothing.
|
|
try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 300));
|
|
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
|
|
}
|
|
|
|
test "a failed prune leaves both tables as they were" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
|
|
});
|
|
try database.exec(
|
|
\\CREATE TRIGGER refuse_target_delete BEFORE DELETE ON upstream_targets
|
|
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
|
);
|
|
|
|
// The minute delete succeeds and the target delete does not; one
|
|
// transaction means neither survives.
|
|
try testing.expectError(error.Constraint, pruneOlderThan(&database, 300));
|
|
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
|
|
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
|
|
}
|
|
|
|
test "an error name longer than the buffer is truncated, not overflowed" {
|
|
var database = try openLog();
|
|
defer database.close();
|
|
const long = "A" ** 200;
|
|
try flush(&database, &.{
|
|
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = long },
|
|
});
|
|
|
|
const stats = try windowStats(&database, "https://a.example", 60, 120);
|
|
try testing.expectEqual(@as(usize, health.error_name_capacity), stats.lastFailureError().len);
|
|
try testing.expectEqualStrings(long[0..health.error_name_capacity], stats.lastFailureError());
|
|
}
|