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
1178 lines
47 KiB
Zig
1178 lines
47 KiB
Zig
//! `operational_events` in `config.db` — the SQL behind `storage/events.zig`.
|
|
//!
|
|
//! One row is one *episode*: a failure of one subject that opened at
|
|
//! `first_seen`, was last seen at `last_seen`, has happened `occurrences` times,
|
|
//! and is either still going (`resolved_at IS NULL`) or over. A subject that
|
|
//! fails, recovers and fails again gets a second row, never a reopened first
|
|
//! one, so the history reads as a sequence of episodes rather than one row whose
|
|
//! meaning changes.
|
|
//!
|
|
//! The partial unique index on `(code, subject_key) WHERE resolved_at IS NULL`
|
|
//! is what enforces that: at most one active row per subject, and any number of
|
|
//! resolved ones. Every function here is written to work with it rather than
|
|
//! around it.
|
|
//!
|
|
//! Only `events.Store` calls this in production, and it holds its mutex across
|
|
//! every call — nothing here takes a lock or assumes one. Strings are borrowed
|
|
//! for the duration of the call: `Stmt.bindText` binds with `SQLITE_TRANSIENT`,
|
|
//! so SQLite copies before the function returns.
|
|
//!
|
|
//! Nothing here retries and nothing here logs. The store owns what a failed
|
|
//! write means.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const db = @import("../db.zig");
|
|
|
|
/// The stored widths. `events.Store` re-exports them as its own caps, so a
|
|
/// value that reaches a column is always a value the column can hold, and the
|
|
/// mirror's fixed row buffers below are always big enough for a stored row.
|
|
pub const code_capacity = 32;
|
|
pub const key_capacity = 256;
|
|
|
|
/// Ruling 11's page cap, shared with `/api/queries` so a client cannot ask
|
|
/// either endpoint for a whole table.
|
|
pub const max_limit: u32 = 1000;
|
|
|
|
pub const severity_warning = "warning";
|
|
pub const severity_error = "error";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// writes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const insert_sql =
|
|
\\INSERT INTO operational_events
|
|
\\ (code, subject_key, subject_label, severity,
|
|
\\ first_seen, last_seen, occurrences, resolved_at, detail)
|
|
\\VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1, ?6, ?7)
|
|
;
|
|
|
|
/// Opens an episode and returns its row id. `resolved_at` is NULL, so the
|
|
/// partial unique index refuses a second active row for the same subject —
|
|
/// which is why the store probes before it inserts whenever it cannot see the
|
|
/// whole active set.
|
|
pub fn insertActive(
|
|
database: *db.Db,
|
|
now_s: i64,
|
|
code: []const u8,
|
|
subject_key: []const u8,
|
|
subject_label: []const u8,
|
|
severity: []const u8,
|
|
detail: []const u8,
|
|
) db.Error!i64 {
|
|
try insertRow(database, now_s, code, subject_key, subject_label, severity, detail, null);
|
|
return database.lastInsertRowid();
|
|
}
|
|
|
|
/// A one-shot event: something that happened once and is already over
|
|
/// (`query_log.recreated`). It never becomes an active row, so it never
|
|
/// competes for the partial unique index and never needs resolving.
|
|
pub fn insertResolved(
|
|
database: *db.Db,
|
|
now_s: i64,
|
|
code: []const u8,
|
|
subject_key: []const u8,
|
|
subject_label: []const u8,
|
|
severity: []const u8,
|
|
detail: []const u8,
|
|
) db.Error!void {
|
|
return insertRow(database, now_s, code, subject_key, subject_label, severity, detail, now_s);
|
|
}
|
|
|
|
fn insertRow(
|
|
database: *db.Db,
|
|
now_s: i64,
|
|
code: []const u8,
|
|
subject_key: []const u8,
|
|
subject_label: []const u8,
|
|
severity: []const u8,
|
|
detail: []const u8,
|
|
resolved_at: ?i64,
|
|
) db.Error!void {
|
|
var stmt = try database.prepare(insert_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, code);
|
|
try stmt.bindText(2, subject_key);
|
|
try stmt.bindText(3, subject_label);
|
|
try stmt.bindText(4, severity);
|
|
try stmt.bindInt(5, now_s);
|
|
if (resolved_at) |at| try stmt.bindInt(6, at) else try stmt.bindNull(6);
|
|
try stmt.bindText(7, detail);
|
|
return stmt.exec();
|
|
}
|
|
|
|
/// The severity `CASE` is the "raise, never lower" rule: a warning episode that
|
|
/// takes an error becomes an error episode, and an error episode that takes a
|
|
/// warning stays an error. Written in SQL rather than read-modify-write so the
|
|
/// rule holds in one statement.
|
|
const touch_sql =
|
|
\\UPDATE operational_events
|
|
\\ SET last_seen = ?2,
|
|
\\ occurrences = occurrences + 1,
|
|
\\ detail = ?3,
|
|
\\ severity = CASE WHEN ?4 = 'error' THEN 'error' ELSE severity END
|
|
\\ WHERE id = ?1 AND resolved_at IS NULL
|
|
;
|
|
|
|
/// Another failure of an episode already open. False means the row was not
|
|
/// there or was already resolved — the store's mirror was stale.
|
|
pub fn touchActive(
|
|
database: *db.Db,
|
|
id: i64,
|
|
now_s: i64,
|
|
severity: []const u8,
|
|
detail: []const u8,
|
|
) db.Error!bool {
|
|
var stmt = try database.prepare(touch_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.bindInt(2, now_s);
|
|
try stmt.bindText(3, detail);
|
|
try stmt.bindText(4, severity);
|
|
try stmt.exec();
|
|
return database.changes() != 0;
|
|
}
|
|
|
|
const resolve_by_id_sql =
|
|
"UPDATE operational_events SET resolved_at = ?2 WHERE id = ?1 AND resolved_at IS NULL";
|
|
|
|
/// Closes one episode by row id. False means it was not open, which the store
|
|
/// treats as a stale mirror entry rather than an error.
|
|
pub fn resolveActive(database: *db.Db, id: i64, now_s: i64) db.Error!bool {
|
|
var stmt = try database.prepare(resolve_by_id_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.bindInt(2, now_s);
|
|
try stmt.exec();
|
|
return database.changes() != 0;
|
|
}
|
|
|
|
const resolve_by_key_sql =
|
|
\\UPDATE operational_events SET resolved_at = ?3
|
|
\\ WHERE code = ?1 AND subject_key = ?2 AND resolved_at IS NULL
|
|
;
|
|
|
|
/// Closes one episode by subject. This is the slow path the store takes only
|
|
/// when its mirror cannot answer — the mirror is full and some active row is
|
|
/// outside it.
|
|
pub fn resolveActiveByKey(
|
|
database: *db.Db,
|
|
now_s: i64,
|
|
code: []const u8,
|
|
subject_key: []const u8,
|
|
) db.Error!bool {
|
|
var stmt = try database.prepare(resolve_by_key_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, code);
|
|
try stmt.bindText(2, subject_key);
|
|
try stmt.bindInt(3, now_s);
|
|
try stmt.exec();
|
|
return database.changes() != 0;
|
|
}
|
|
|
|
const select_active_id_sql =
|
|
"SELECT id FROM operational_events WHERE code = ?1 AND subject_key = ?2 AND resolved_at IS NULL";
|
|
|
|
/// The row id of the open episode for a subject, or null when there is none.
|
|
///
|
|
/// The store's overflow probe: when active rows exist outside its mirror it
|
|
/// cannot know whether a subject already has one open, and inserting blind
|
|
/// would collide with the partial unique index. It never runs while the mirror
|
|
/// covers every active row.
|
|
pub fn selectActiveId(database: *db.Db, code: []const u8, subject_key: []const u8) db.Error!?i64 {
|
|
var stmt = try database.prepare(select_active_id_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, code);
|
|
try stmt.bindText(2, subject_key);
|
|
if (!try stmt.step()) return null;
|
|
return stmt.columnInt(0);
|
|
}
|
|
|
|
/// The value phase one stamps on the rows it claims, so phase two can find
|
|
/// exactly those and no others.
|
|
///
|
|
/// A timestamp cannot serve: `resolveExcept` may run in the same second as an
|
|
/// ordinary `resolve`, and a mark of `now_s` would make phase two revive the
|
|
/// episode that `resolve` had just closed. It must also satisfy the table's
|
|
/// `resolved_at >= first_seen` check, which rules out a negative sentinel, and
|
|
/// it never reaches a reader — the three statements are one transaction, and a
|
|
/// rollback takes the mark with it.
|
|
const sweep_mark: i64 = std.math.maxInt(i64);
|
|
|
|
const mark_all_of_code_sql =
|
|
"UPDATE operational_events SET resolved_at = ?2 WHERE code = ?1 AND resolved_at IS NULL";
|
|
|
|
/// Un-marks the row phase one claimed for `subject_key`, leaving the episode
|
|
/// open. The partial unique index allows only one active row per
|
|
/// `(code, subject_key)`, so there is at most one to find; `ORDER BY id DESC
|
|
/// LIMIT 1` says so to SQLite rather than trusting the plan.
|
|
const unmark_kept_sql =
|
|
\\UPDATE operational_events SET resolved_at = NULL
|
|
\\ WHERE id = (SELECT id FROM operational_events
|
|
\\ WHERE code = ?1 AND subject_key = ?2 AND resolved_at = ?3
|
|
\\ ORDER BY id DESC LIMIT 1)
|
|
;
|
|
|
|
/// Turns what is still marked — the episodes no kept key claimed — into a real
|
|
/// resolve at the caller's instant.
|
|
const resolve_marked_sql =
|
|
"UPDATE operational_events SET resolved_at = ?2 WHERE code = ?1 AND resolved_at = ?3";
|
|
|
|
const count_active_sql = "SELECT count(*) FROM operational_events WHERE resolved_at IS NULL";
|
|
|
|
/// Resolves every active episode of `code` whose subject is not in `kept`, and
|
|
/// returns how many active rows the **whole table** has left.
|
|
///
|
|
/// Mark-then-unmark rather than a `NOT IN` list, so the statement text is fixed
|
|
/// no matter how many keys arrive and each key is bound on its own. One
|
|
/// transaction, so no reader ever sees a marked row, and only the rows this
|
|
/// call marked can be touched by the two statements after it: an episode some
|
|
/// other write resolved a moment earlier is not among them, whatever second it
|
|
/// happened in.
|
|
///
|
|
/// The count comes from inside that transaction and is returned only after the
|
|
/// commit succeeds: the store sets its overflow count from it, and a number
|
|
/// from a transaction that then rolled back would be a lie it kept.
|
|
pub fn resolveExcept(
|
|
database: *db.Db,
|
|
now_s: i64,
|
|
code: []const u8,
|
|
kept: []const []const u8,
|
|
) db.Error!i64 {
|
|
var tx = try db.Tx.begin(database);
|
|
errdefer tx.rollback();
|
|
|
|
{
|
|
var stmt = try database.prepare(mark_all_of_code_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, code);
|
|
try stmt.bindInt(2, sweep_mark);
|
|
try stmt.exec();
|
|
}
|
|
|
|
if (kept.len != 0) {
|
|
var stmt = try database.prepare(unmark_kept_sql);
|
|
defer stmt.deinit();
|
|
for (kept) |key| {
|
|
try stmt.reset();
|
|
try stmt.bindText(1, code);
|
|
try stmt.bindText(2, key);
|
|
try stmt.bindInt(3, sweep_mark);
|
|
try stmt.exec();
|
|
}
|
|
}
|
|
|
|
{
|
|
var stmt = try database.prepare(resolve_marked_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, code);
|
|
try stmt.bindInt(2, now_s);
|
|
try stmt.bindInt(3, sweep_mark);
|
|
try stmt.exec();
|
|
}
|
|
|
|
const total_active = try database.queryInt(count_active_sql);
|
|
try tx.commit();
|
|
return total_active;
|
|
}
|
|
|
|
/// What `purgeResolved` did. `active` and `absent` are different answers to the
|
|
/// operator — one says "not yet", the other "already gone" — so the delete alone
|
|
/// cannot report the outcome and the row is probed when it deletes nothing.
|
|
pub const PurgeOutcome = enum { deleted, active, absent };
|
|
|
|
const purge_one_sql =
|
|
"DELETE FROM operational_events WHERE id = ?1 AND resolved_at IS NOT NULL";
|
|
|
|
const row_exists_sql = "SELECT 1 FROM operational_events WHERE id = ?1";
|
|
|
|
/// The operator's manual delete of one resolved event.
|
|
///
|
|
/// The `resolved_at IS NOT NULL` clause is the whole guarantee: an open episode
|
|
/// is the current state of the box, and deleting it would make the store's
|
|
/// mirror point at a row that no longer exists. A delete that removed nothing is
|
|
/// probed, in the same transaction, so `active` and `absent` cannot be confused
|
|
/// by a row that resolved between the two statements.
|
|
pub fn purgeResolved(database: *db.Db, id: i64) db.Error!PurgeOutcome {
|
|
var tx = try db.Tx.begin(database);
|
|
errdefer tx.rollback();
|
|
|
|
{
|
|
var stmt = try database.prepare(purge_one_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.exec();
|
|
}
|
|
|
|
const outcome: PurgeOutcome = if (database.changes() != 0) .deleted else blk: {
|
|
var stmt = try database.prepare(row_exists_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
break :blk if (try stmt.step()) .active else .absent;
|
|
};
|
|
|
|
try tx.commit();
|
|
return outcome;
|
|
}
|
|
|
|
const purge_all_resolved_sql = "DELETE FROM operational_events WHERE resolved_at IS NOT NULL";
|
|
|
|
/// Drops every resolved row and returns how many went. Active episodes are
|
|
/// never candidates, so the store's mirror describes the same set of rows before
|
|
/// and after.
|
|
pub fn purgeAllResolved(database: *db.Db) db.Error!i64 {
|
|
var stmt = try database.prepare(purge_all_resolved_sql);
|
|
defer stmt.deinit();
|
|
try stmt.exec();
|
|
return database.changes();
|
|
}
|
|
|
|
const prune_expired_sql =
|
|
"DELETE FROM operational_events WHERE resolved_at IS NOT NULL AND resolved_at < ?1";
|
|
|
|
/// The cap keeps the newest resolved rows by the same order the API pages them
|
|
/// in, so the row that survives the cap is the row a reader would have seen
|
|
/// first. Active rows are never candidates: an episode still going is the state
|
|
/// of the box, not history.
|
|
const prune_over_cap_sql =
|
|
\\DELETE FROM operational_events
|
|
\\ WHERE resolved_at IS NOT NULL AND id NOT IN (
|
|
\\ SELECT id FROM operational_events WHERE resolved_at IS NOT NULL
|
|
\\ ORDER BY resolved_at DESC, id DESC LIMIT ?1)
|
|
;
|
|
|
|
/// Drops resolved rows older than `cutoff_s`, then resolved rows beyond
|
|
/// `max_rows`, and returns how many went. One transaction: two passes that
|
|
/// half-applied would leave the cap enforced against a set the retention pass
|
|
/// had already changed.
|
|
pub fn pruneResolved(database: *db.Db, cutoff_s: i64, max_rows: i64) db.Error!i64 {
|
|
var tx = try db.Tx.begin(database);
|
|
errdefer tx.rollback();
|
|
|
|
var deleted: i64 = 0;
|
|
{
|
|
var stmt = try database.prepare(prune_expired_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, cutoff_s);
|
|
try stmt.exec();
|
|
deleted += database.changes();
|
|
}
|
|
{
|
|
var stmt = try database.prepare(prune_over_cap_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, max_rows);
|
|
try stmt.exec();
|
|
deleted += database.changes();
|
|
}
|
|
|
|
try tx.commit();
|
|
return deleted;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the store's mirror
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One active row, as the store's mirror needs it. By value with fixed buffers
|
|
/// rather than by slice: the store loads these into a stack array and the
|
|
/// statement's own storage dies at the next `step`.
|
|
pub const ActiveRow = struct {
|
|
id: i64,
|
|
code_buf: [code_capacity]u8,
|
|
code_len: u8,
|
|
key_buf: [key_capacity]u8,
|
|
key_len: u16,
|
|
|
|
pub fn code(self: *const ActiveRow) []const u8 {
|
|
return self.code_buf[0..self.code_len];
|
|
}
|
|
|
|
pub fn subjectKey(self: *const ActiveRow) []const u8 {
|
|
return self.key_buf[0..self.key_len];
|
|
}
|
|
};
|
|
|
|
/// The length bounds are not defensive noise. A row wider than the mirror's
|
|
/// buffers cannot be mirrored, and truncating it would give the store a *wrong*
|
|
/// identity — a later resolve would match the truncation and close the wrong
|
|
/// episode, or miss and silently no-op. Excluding it here leaves it counted as
|
|
/// untracked by `countActive`, which is the honest outcome.
|
|
const load_active_sql =
|
|
\\SELECT id, code, subject_key FROM operational_events
|
|
\\ WHERE resolved_at IS NULL AND id > ?1
|
|
\\ AND length(code) <= ?3 AND length(subject_key) <= ?4
|
|
\\ ORDER BY id LIMIT ?2
|
|
;
|
|
|
|
/// Fills `out` with active rows whose id is above `after_id`, ascending, and
|
|
/// returns how many were written. The store pages through with the last id it
|
|
/// received, so nothing here has to hold a cursor open across a call.
|
|
pub fn loadActive(database: *db.Db, out: []ActiveRow, after_id: i64) db.Error!usize {
|
|
if (out.len == 0) return 0;
|
|
|
|
var stmt = try database.prepare(load_active_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, after_id);
|
|
try stmt.bindInt(2, @intCast(out.len));
|
|
try stmt.bindInt(3, code_capacity);
|
|
try stmt.bindInt(4, key_capacity);
|
|
|
|
var written: usize = 0;
|
|
while (written < out.len and try stmt.step()) {
|
|
const code = stmt.columnText(1);
|
|
const key = stmt.columnText(2);
|
|
// The `length()` bounds above are on characters and these are on bytes;
|
|
// a multi-byte code or key could pass the first and not the second.
|
|
if (code.len > code_capacity or key.len > key_capacity) continue;
|
|
|
|
const row = &out[written];
|
|
row.* = .{
|
|
.id = stmt.columnInt(0),
|
|
.code_buf = @splat(0),
|
|
.code_len = @intCast(code.len),
|
|
.key_buf = @splat(0),
|
|
.key_len = @intCast(key.len),
|
|
};
|
|
@memcpy(row.code_buf[0..code.len], code);
|
|
@memcpy(row.key_buf[0..key.len], key);
|
|
written += 1;
|
|
}
|
|
return written;
|
|
}
|
|
|
|
/// Every active row, mirrored or not. The store's overflow count is this minus
|
|
/// what it managed to mirror.
|
|
pub fn countActive(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt(count_active_sql);
|
|
}
|
|
|
|
/// What `/api/health` and `/metrics` report, and the `active` block of the
|
|
/// diagnostics page.
|
|
pub const Counts = struct {
|
|
warnings: u32 = 0,
|
|
errors: u32 = 0,
|
|
};
|
|
|
|
const active_counts_sql =
|
|
\\SELECT coalesce(sum(severity = 'warning'), 0), coalesce(sum(severity = 'error'), 0)
|
|
\\ FROM operational_events WHERE resolved_at IS NULL
|
|
;
|
|
|
|
pub fn activeCounts(database: *db.Db) db.Error!Counts {
|
|
var stmt = try database.prepare(active_counts_sql);
|
|
defer stmt.deinit();
|
|
// 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;
|
|
return .{
|
|
.warnings = try countOf(stmt.columnInt(0)),
|
|
.errors = try countOf(stmt.columnInt(1)),
|
|
};
|
|
}
|
|
|
|
/// `sum` over a boolean cannot go negative; a negative value means the row came
|
|
/// from something other than this schema.
|
|
fn countOf(value: i64) db.Error!u32 {
|
|
if (value < 0) return error.Mismatch;
|
|
return std.math.cast(u32, value) orelse error.Mismatch;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the API read layer (`GET /api/diagnostics`)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One event as the API serializes it. `subject_key` deliberately has no field
|
|
/// here: it is the store's identity for a subject and may hold an upstream url
|
|
/// with a token in it, so it never leaves the process. `subject` is the
|
|
/// redacted display identity.
|
|
pub const Event = struct {
|
|
id: i64,
|
|
code: []const u8,
|
|
component: []const u8,
|
|
subject: []const u8,
|
|
severity: []const u8,
|
|
first_seen: i64,
|
|
last_seen: i64,
|
|
occurrences: i64,
|
|
resolved_at: ?i64,
|
|
detail: []const u8,
|
|
};
|
|
|
|
pub const State = enum { active, resolved, all };
|
|
|
|
/// Every field is an independent narrowing; `null` means "do not filter on it".
|
|
pub const Filter = struct {
|
|
state: State = .all,
|
|
/// `"warning"` or `"error"`, validated by the handler.
|
|
severity: ?[]const u8 = null,
|
|
/// Matched on the part of `code` before the dot.
|
|
component: ?[]const u8 = null,
|
|
/// The window an episode must overlap. `since` is exclusive at the
|
|
/// resolution end and `until` is exclusive at the start end — see
|
|
/// `where_window`.
|
|
since: ?i64 = null,
|
|
until: ?i64 = null,
|
|
limit: u32 = 100,
|
|
/// Keyset cursor: only rows with a strictly smaller `id`. Rows come back
|
|
/// newest-first, so this is the id of the last row of the previous page.
|
|
before: ?i64 = null,
|
|
};
|
|
|
|
const select_head =
|
|
\\SELECT id, code, subject_label, severity, first_seen, last_seen,
|
|
\\ occurrences, resolved_at, detail
|
|
\\ FROM operational_events
|
|
;
|
|
|
|
/// The escape character of `where_component`. SQLite does not give string
|
|
/// literals C escapes, so `'\'` in the SQL text is one backslash.
|
|
const like_escape = '\\';
|
|
|
|
const where_active = " resolved_at IS NULL";
|
|
const where_resolved = " resolved_at IS NOT NULL";
|
|
const where_severity = " severity = ?";
|
|
const where_component = " code LIKE ? ESCAPE '\\'";
|
|
const where_before = " id < ?";
|
|
|
|
/// An episode overlaps `[since, until)` when it started before the window ended
|
|
/// and had not already been resolved when the window began. `resolved_at >
|
|
/// since` is strict, matching `queries_repo`: an episode resolved exactly at
|
|
/// `since` is over by the time the window opens.
|
|
const where_since = " (resolved_at IS NULL OR resolved_at > ?)";
|
|
const where_until = " first_seen < ?";
|
|
|
|
const select_tail = " ORDER BY id DESC LIMIT ?";
|
|
|
|
const where_keyword = " WHERE";
|
|
const and_keyword = " AND";
|
|
|
|
/// Assembles the statement from the fixed fragments above and nothing else.
|
|
///
|
|
/// **No value ever reaches this buffer.** Every filter contributes a `?` and is
|
|
/// bound afterwards, in the order the predicates were appended: an unnumbered
|
|
/// parameter takes the next free index, so append order and bind order are the
|
|
/// same single contract.
|
|
const Sql = struct {
|
|
/// `where_keyword` is longer than `and_keyword` and is used at most once,
|
|
/// so counting six of it bounds every reachable combination.
|
|
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
|
|
where_resolved.len + where_severity.len + where_component.len +
|
|
where_since.len + where_until.len + where_before.len;
|
|
|
|
buf: [capacity]u8 = undefined,
|
|
len: usize = 0,
|
|
has_where: bool = false,
|
|
|
|
fn put(self: *Sql, fragment: []const u8) void {
|
|
@memcpy(self.buf[self.len..][0..fragment.len], fragment);
|
|
self.len += fragment.len;
|
|
}
|
|
|
|
fn predicate(self: *Sql, fragment: []const u8) void {
|
|
self.put(if (self.has_where) and_keyword else where_keyword);
|
|
self.has_where = true;
|
|
self.put(fragment);
|
|
}
|
|
|
|
fn text(self: *const Sql) []const u8 {
|
|
return self.buf[0..self.len];
|
|
}
|
|
};
|
|
|
|
/// Rows come back newest-first (`id DESC`). Every string is allocated from
|
|
/// `arena`, including the list's own storage, so the caller frees the whole
|
|
/// result by resetting the arena — there is nothing to unwind on failure.
|
|
///
|
|
/// An empty window returns nothing rather than everything: `since >= until`
|
|
/// describes no time at all, and a filter that answers a question the client
|
|
/// did not ask is worse than an empty page.
|
|
pub fn selectEvents(database: *db.Db, arena: Allocator, filter: Filter) db.Error!std.ArrayList(Event) {
|
|
var out: std.ArrayList(Event) = .empty;
|
|
if (filter.since) |since| {
|
|
if (filter.until) |until| {
|
|
if (since >= until) return out;
|
|
}
|
|
}
|
|
|
|
var sql: Sql = .{};
|
|
sql.put(select_head);
|
|
switch (filter.state) {
|
|
.active => sql.predicate(where_active),
|
|
.resolved => sql.predicate(where_resolved),
|
|
.all => {},
|
|
}
|
|
if (filter.severity != null) sql.predicate(where_severity);
|
|
if (filter.component != null) sql.predicate(where_component);
|
|
if (filter.since != null) sql.predicate(where_since);
|
|
if (filter.until != null) sql.predicate(where_until);
|
|
if (filter.before != null) sql.predicate(where_before);
|
|
sql.put(select_tail);
|
|
|
|
var stmt = try database.prepare(sql.text());
|
|
defer stmt.deinit();
|
|
|
|
var idx: c_int = 0;
|
|
if (filter.severity) |v| {
|
|
idx += 1;
|
|
try stmt.bindText(idx, v);
|
|
}
|
|
if (filter.component) |v| {
|
|
idx += 1;
|
|
try stmt.bindText(idx, try componentPattern(arena, v));
|
|
}
|
|
if (filter.since) |v| {
|
|
idx += 1;
|
|
try stmt.bindInt(idx, v);
|
|
}
|
|
if (filter.until) |v| {
|
|
idx += 1;
|
|
try stmt.bindInt(idx, v);
|
|
}
|
|
if (filter.before) |v| {
|
|
idx += 1;
|
|
try stmt.bindInt(idx, v);
|
|
}
|
|
idx += 1;
|
|
try stmt.bindInt(idx, @min(filter.limit, max_limit));
|
|
|
|
while (try stmt.step()) {
|
|
try out.append(arena, try rowOf(&stmt, arena));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const select_one_sql = select_head ++ " WHERE id = ?1";
|
|
|
|
/// Null for an id that never existed and for one retention has since removed —
|
|
/// the API cannot tell those apart and does not pretend to.
|
|
pub fn selectOne(database: *db.Db, arena: Allocator, id: i64) db.Error!?Event {
|
|
var stmt = try database.prepare(select_one_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
if (!try stmt.step()) return null;
|
|
return try rowOf(&stmt, arena);
|
|
}
|
|
|
|
fn rowOf(stmt: *db.Stmt, arena: Allocator) db.Error!Event {
|
|
const code = try stmt.columnTextAlloc(arena, 1);
|
|
return .{
|
|
.id = stmt.columnInt(0),
|
|
.code = code,
|
|
.component = componentOf(code),
|
|
.subject = try stmt.columnTextAlloc(arena, 2),
|
|
.severity = try stmt.columnTextAlloc(arena, 3),
|
|
.first_seen = stmt.columnInt(4),
|
|
.last_seen = stmt.columnInt(5),
|
|
.occurrences = stmt.columnInt(6),
|
|
.resolved_at = if (stmt.isNull(7)) null else stmt.columnInt(7),
|
|
.detail = try stmt.columnTextAlloc(arena, 8),
|
|
};
|
|
}
|
|
|
|
/// The part of a wire code before the dot, borrowed from `code` itself. A code
|
|
/// with no dot is its own component; `events.Code` cannot produce one, and a
|
|
/// row that came from somewhere else still gets an answer rather than a crash.
|
|
pub fn componentOf(code: []const u8) []const u8 {
|
|
const dot = std.mem.indexOfScalar(u8, code, '.') orelse return code;
|
|
return code[0..dot];
|
|
}
|
|
|
|
/// `component.%`, with the two `LIKE` metacharacters neutralised so a filter of
|
|
/// `query_log` matches `query_log.write` and not `queryxlog.write`. The escape
|
|
/// character escapes itself.
|
|
fn componentPattern(arena: Allocator, component: []const u8) Allocator.Error![]const u8 {
|
|
var out: std.ArrayList(u8) = try .initCapacity(arena, component.len * 2 + 2);
|
|
for (component) |ch| {
|
|
if (ch == '%' or ch == '_' or ch == like_escape) out.appendAssumeCapacity(like_escape);
|
|
out.appendAssumeCapacity(ch);
|
|
}
|
|
out.appendAssumeCapacity('.');
|
|
out.appendAssumeCapacity('%');
|
|
return out.items;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const migrations = @import("../migrations.zig");
|
|
const testing = std.testing;
|
|
|
|
fn openConfig() !db.Db {
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
return database;
|
|
}
|
|
|
|
fn open(database: *db.Db, now_s: i64, code: []const u8, key: []const u8) !i64 {
|
|
return insertActive(database, now_s, code, key, key, severity_warning, "first");
|
|
}
|
|
|
|
test "an episode opens active and closes exactly once" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
const id = try open(&database, 100, "blocklist.refresh", "https://a.example");
|
|
try testing.expectEqual(@as(i64, 1), try countActive(&database));
|
|
|
|
try testing.expect(try resolveActive(&database, id, 200));
|
|
try testing.expectEqual(@as(i64, 0), try countActive(&database));
|
|
// Already closed: the second call is a no-op, not an error, and it must not
|
|
// move `resolved_at` to the later instant.
|
|
try testing.expect(!try resolveActive(&database, id, 300));
|
|
try testing.expectEqual(
|
|
@as(i64, 200),
|
|
try database.queryInt("SELECT resolved_at FROM operational_events"),
|
|
);
|
|
}
|
|
|
|
test "touchActive counts the failure, replaces the detail and raises severity without lowering it" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
const id = try open(&database, 100, "disk.space", "data");
|
|
|
|
try testing.expect(try touchActive(&database, id, 150, severity_warning, "second"));
|
|
try testing.expect(try touchActive(&database, id, 200, severity_error, "third"));
|
|
// A warning after an error leaves the episode an error: the row records the
|
|
// worst the subject reached, not the last thing it did.
|
|
try testing.expect(try touchActive(&database, id, 250, severity_warning, "fourth"));
|
|
|
|
var stmt = try database.prepare(
|
|
"SELECT occurrences, last_seen, first_seen, severity, detail FROM operational_events WHERE id = ?1",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try testing.expect(try stmt.step());
|
|
try testing.expectEqual(@as(i64, 4), stmt.columnInt(0));
|
|
try testing.expectEqual(@as(i64, 250), stmt.columnInt(1));
|
|
try testing.expectEqual(@as(i64, 100), stmt.columnInt(2));
|
|
try testing.expectEqualStrings("error", stmt.columnText(3));
|
|
try testing.expectEqualStrings("fourth", stmt.columnText(4));
|
|
}
|
|
|
|
test "touching or resolving a row that is not open reports it rather than failing" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
const id = try open(&database, 100, "disk.space", "data");
|
|
try testing.expect(try resolveActive(&database, id, 200));
|
|
|
|
// What a stale mirror entry looks like from here.
|
|
try testing.expect(!try touchActive(&database, id, 300, severity_warning, "late"));
|
|
try testing.expect(!try resolveActive(&database, 9999, 300));
|
|
try testing.expect(!try resolveActiveByKey(&database, 300, "disk.space", "data"));
|
|
}
|
|
|
|
test "resolveActiveByKey closes the open episode and leaves the earlier ones alone" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
const first = try open(&database, 100, "upstream.exchange", "https://a.example");
|
|
try testing.expect(try resolveActive(&database, first, 150));
|
|
const second = try open(&database, 200, "upstream.exchange", "https://a.example");
|
|
|
|
try testing.expect(try resolveActiveByKey(&database, 300, "upstream.exchange", "https://a.example"));
|
|
try testing.expectEqual(@as(i64, 0), try countActive(&database));
|
|
var stmt = try database.prepare("SELECT resolved_at FROM operational_events ORDER BY id");
|
|
defer stmt.deinit();
|
|
try testing.expect(try stmt.step());
|
|
try testing.expectEqual(@as(i64, 150), stmt.columnInt(0));
|
|
try testing.expect(try stmt.step());
|
|
try testing.expectEqual(@as(i64, 300), stmt.columnInt(0));
|
|
// By key, so the row it closed is the one the key had open.
|
|
try testing.expectEqual(
|
|
second,
|
|
try database.queryInt("SELECT id FROM operational_events WHERE resolved_at = 300"),
|
|
);
|
|
}
|
|
|
|
test "insertResolved writes a row that is over the moment it exists" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
try insertResolved(&database, 100, "query_log.recreated", "one-shot", "corrupt", severity_warning, "aside");
|
|
|
|
try testing.expectEqual(@as(i64, 0), try countActive(&database));
|
|
var stmt = try database.prepare("SELECT first_seen, last_seen, resolved_at, occurrences FROM operational_events");
|
|
defer stmt.deinit();
|
|
try testing.expect(try stmt.step());
|
|
for (0..3) |col| try testing.expectEqual(@as(i64, 100), stmt.columnInt(@intCast(col)));
|
|
try testing.expectEqual(@as(i64, 1), stmt.columnInt(3));
|
|
|
|
// Nothing stops a second one: a one-shot event never competes for the
|
|
// partial unique index, because it is never active.
|
|
try insertResolved(&database, 200, "query_log.recreated", "one-shot", "corrupt", severity_warning, "aside");
|
|
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM operational_events"));
|
|
}
|
|
|
|
test "selectActiveId finds the open episode of a subject and nothing else" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
const id = try open(&database, 100, "disk.probe", "statvfs");
|
|
try testing.expectEqual(@as(?i64, id), try selectActiveId(&database, "disk.probe", "statvfs"));
|
|
// Another code with the same subject is a different episode.
|
|
try testing.expectEqual(@as(?i64, null), try selectActiveId(&database, "disk.space", "statvfs"));
|
|
|
|
try testing.expect(try resolveActive(&database, id, 200));
|
|
// A closed episode is not an open one, which is what makes the store's
|
|
// probe answer "insert" rather than "touch".
|
|
try testing.expectEqual(@as(?i64, null), try selectActiveId(&database, "disk.probe", "statvfs"));
|
|
}
|
|
|
|
test "resolveExcept closes what is not kept, keeps what is, and reports the whole table's active rows" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
_ = try open(&database, 100, "listener.start", "doh");
|
|
_ = try open(&database, 100, "listener.start", "dot");
|
|
_ = try open(&database, 100, "configuration.load", "dns.port");
|
|
|
|
const remaining = try resolveExcept(&database, 500, "listener.start", &.{"dot"});
|
|
// `dot` stayed open and `configuration.load` was never this code's business.
|
|
try testing.expectEqual(@as(i64, 2), remaining);
|
|
try testing.expectEqual(@as(i64, 2), try countActive(&database));
|
|
|
|
var stmt = try database.prepare(
|
|
"SELECT resolved_at FROM operational_events WHERE code = 'listener.start' AND subject_key = ?1",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, "doh");
|
|
try testing.expect(try stmt.step());
|
|
try testing.expectEqual(@as(i64, 500), stmt.columnInt(0));
|
|
}
|
|
|
|
test "a kept key revives only the row this pass resolved, never an older episode" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
// An episode of the same subject that was already over — and over at the
|
|
// very instant this pass stamps, which is the case the `ORDER BY id DESC
|
|
// LIMIT 1` bound exists for.
|
|
const stale = try open(&database, 100, "listener.start", "doh");
|
|
try testing.expect(try resolveActive(&database, stale, 500));
|
|
const live = try open(&database, 200, "listener.start", "doh");
|
|
|
|
try testing.expectEqual(@as(i64, 1), try resolveExcept(&database, 500, "listener.start", &.{"doh"}));
|
|
try testing.expectEqual(@as(i64, 1), try countActive(&database));
|
|
try testing.expectEqual(
|
|
live,
|
|
try database.queryInt("SELECT id FROM operational_events WHERE resolved_at IS NULL"),
|
|
);
|
|
// The old episode is still closed, at the timestamp it always had.
|
|
try testing.expectEqual(
|
|
@as(i64, 500),
|
|
try database.queryInt("SELECT resolved_at FROM operational_events WHERE id = 1"),
|
|
);
|
|
}
|
|
|
|
test "resolveExcept with no kept keys closes every episode of the code" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
_ = try open(&database, 100, "configuration.load", "dns.port");
|
|
_ = try open(&database, 100, "configuration.load", "upstreams[0]");
|
|
_ = try open(&database, 100, "disk.space", "data");
|
|
|
|
try testing.expectEqual(@as(i64, 1), try resolveExcept(&database, 500, "configuration.load", &.{}));
|
|
try testing.expectEqual(@as(i64, 1), try countActive(&database));
|
|
}
|
|
|
|
test "a failed resolveExcept leaves every row as it was" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
_ = try open(&database, 100, "listener.start", "doh");
|
|
try database.exec(
|
|
\\CREATE TRIGGER refuse_revive AFTER UPDATE ON operational_events
|
|
\\WHEN new.resolved_at IS NULL
|
|
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
|
);
|
|
|
|
// Phase one resolved the row and phase two could not put it back; one
|
|
// transaction is what stops the pass from closing an episode it meant to
|
|
// keep.
|
|
try testing.expectError(error.Constraint, resolveExcept(&database, 500, "listener.start", &.{"doh"}));
|
|
try testing.expectEqual(@as(i64, 1), try countActive(&database));
|
|
}
|
|
|
|
test "activeCounts splits open episodes by severity and ignores resolved ones" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
try testing.expectEqual(Counts{}, try activeCounts(&database));
|
|
|
|
_ = try open(&database, 100, "disk.space", "data");
|
|
_ = try insertActive(&database, 100, "listener.start", "doh", "doh", severity_error, "bind");
|
|
const gone = try open(&database, 100, "upstream.exchange", "https://a.example");
|
|
try testing.expect(try resolveActive(&database, gone, 200));
|
|
|
|
try testing.expectEqual(Counts{ .warnings = 1, .errors = 1 }, try activeCounts(&database));
|
|
}
|
|
|
|
test "loadActive pages by id and skips a row too wide for the mirror" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
for (0..5) |i| {
|
|
var key_buf: [8]u8 = undefined;
|
|
const key = try std.fmt.bufPrint(&key_buf, "k{d}", .{i});
|
|
_ = try open(&database, 100, "disk.probe", key);
|
|
}
|
|
// Wider than `key_capacity`: mirroring it under a truncated identity would
|
|
// make a later resolve close the wrong episode.
|
|
const wide = "w" ** (key_capacity + 1);
|
|
_ = try open(&database, 100, "disk.probe", wide);
|
|
|
|
var out: [2]ActiveRow = undefined;
|
|
var seen: usize = 0;
|
|
var after: i64 = 0;
|
|
while (true) {
|
|
const got = try loadActive(&database, &out, after);
|
|
if (got == 0) break;
|
|
for (out[0..got]) |row| {
|
|
try testing.expect(row.subjectKey().len <= key_capacity);
|
|
try testing.expectEqualStrings("disk.probe", row.code());
|
|
seen += 1;
|
|
}
|
|
after = out[got - 1].id;
|
|
}
|
|
try testing.expectEqual(@as(usize, 5), seen);
|
|
// The row that could not be mirrored is still active, so the store's
|
|
// overflow count picks it up as the difference.
|
|
try testing.expectEqual(@as(i64, 6), try countActive(&database));
|
|
}
|
|
|
|
test "prune drops resolved rows past the window and past the cap, never an active one" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
var live_keys: [3][8]u8 = undefined;
|
|
for (&live_keys, 0..) |*buf, i| {
|
|
const key = try std.fmt.bufPrint(buf, "live{d}", .{i});
|
|
_ = try open(&database, 100, "disk.probe", key);
|
|
}
|
|
for (0..6) |i| {
|
|
var key_buf: [8]u8 = undefined;
|
|
const key = try std.fmt.bufPrint(&key_buf, "old{d}", .{i});
|
|
const id = try open(&database, 100, "query_log.maintenance", key);
|
|
try testing.expect(try resolveActive(&database, id, @intCast(1000 + i)));
|
|
}
|
|
|
|
// Nothing is old enough and nothing is over the cap.
|
|
try testing.expectEqual(@as(i64, 0), try pruneResolved(&database, 1000, 10));
|
|
try testing.expectEqual(@as(i64, 9), try database.queryInt("SELECT count(*) FROM operational_events"));
|
|
|
|
// Two fall out of the retention window; the cap then takes the oldest of
|
|
// what is left, and the three active rows are never candidates for either.
|
|
try testing.expectEqual(@as(i64, 3), try pruneResolved(&database, 1002, 3));
|
|
try testing.expectEqual(@as(i64, 3), try countActive(&database));
|
|
try testing.expectEqual(
|
|
@as(i64, 1005),
|
|
try database.queryInt("SELECT max(resolved_at) FROM operational_events"),
|
|
);
|
|
try testing.expectEqual(
|
|
@as(i64, 1003),
|
|
try database.queryInt("SELECT min(resolved_at) FROM operational_events WHERE resolved_at IS NOT NULL"),
|
|
);
|
|
}
|
|
|
|
test "purging one event takes the resolved row, refuses the open one and reports a missing id" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
const open_episode = try open(&database, 100, "disk.space", "data");
|
|
const closed = try open(&database, 100, "query_log.maintenance", "vacuum");
|
|
try testing.expect(try resolveActive(&database, closed, 200));
|
|
|
|
// Still going: the operator is told why, and the row stays.
|
|
try testing.expectEqual(PurgeOutcome.active, try purgeResolved(&database, open_episode));
|
|
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM operational_events"));
|
|
|
|
try testing.expectEqual(PurgeOutcome.deleted, try purgeResolved(&database, closed));
|
|
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM operational_events"));
|
|
try testing.expectEqual(@as(i64, 1), try countActive(&database));
|
|
|
|
// The same id twice: gone is not the same answer as still open.
|
|
try testing.expectEqual(PurgeOutcome.absent, try purgeResolved(&database, closed));
|
|
try testing.expectEqual(PurgeOutcome.absent, try purgeResolved(&database, 9999));
|
|
}
|
|
|
|
test "purging all resolved events counts what it took and leaves every active one" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
_ = try open(&database, 100, "disk.space", "data");
|
|
_ = try open(&database, 100, "listener.start", "doh");
|
|
for (0..3) |i| {
|
|
var key_buf: [8]u8 = undefined;
|
|
const key = try std.fmt.bufPrint(&key_buf, "old{d}", .{i});
|
|
const id = try open(&database, 100, "query_log.maintenance", key);
|
|
try testing.expect(try resolveActive(&database, id, @intCast(200 + i)));
|
|
}
|
|
try insertResolved(&database, 300, "query_log.recreated", "one-shot", "corrupt", severity_warning, "aside");
|
|
|
|
try testing.expectEqual(@as(i64, 4), try purgeAllResolved(&database));
|
|
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM operational_events"));
|
|
try testing.expectEqual(@as(i64, 2), try countActive(&database));
|
|
|
|
// Nothing left to take is zero, not an error.
|
|
try testing.expectEqual(@as(i64, 0), try purgeAllResolved(&database));
|
|
}
|
|
|
|
fn seedPage(database: *db.Db) !void {
|
|
_ = try insertActive(database, 100, "blocklist.refresh", "https://a.example", "A", severity_warning, "d1");
|
|
_ = try insertActive(database, 110, "blocklist.storage", "compile", "compile", severity_error, "d2");
|
|
const closed = try insertActive(database, 120, "query_log.write", "batch", "batch", severity_error, "d3");
|
|
try testing.expect(try resolveActive(database, closed, 400));
|
|
_ = try insertActive(database, 130, "query_log.maintenance", "vacuum", "vacuum", severity_warning, "d4");
|
|
}
|
|
|
|
test "selectEvents returns newest first with the component derived from the code" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
try seedPage(&database);
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena.deinit();
|
|
const rows = try selectEvents(&database, arena.allocator(), .{});
|
|
|
|
try testing.expectEqual(@as(usize, 4), rows.items.len);
|
|
try testing.expectEqualStrings("query_log.maintenance", rows.items[0].code);
|
|
try testing.expectEqualStrings("query_log", rows.items[0].component);
|
|
try testing.expectEqualStrings("blocklist", rows.items[3].component);
|
|
try testing.expectEqualStrings("A", rows.items[3].subject);
|
|
try testing.expectEqual(@as(?i64, null), rows.items[0].resolved_at);
|
|
for (rows.items[0 .. rows.items.len - 1], rows.items[1..]) |newer, older| {
|
|
try testing.expect(newer.id > older.id);
|
|
}
|
|
}
|
|
|
|
test "each filter narrows the page and they compose" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
try seedPage(&database);
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena.deinit();
|
|
const gpa = arena.allocator();
|
|
|
|
try testing.expectEqual(@as(usize, 3), (try selectEvents(&database, gpa, .{ .state = .active })).items.len);
|
|
try testing.expectEqual(@as(usize, 1), (try selectEvents(&database, gpa, .{ .state = .resolved })).items.len);
|
|
try testing.expectEqual(
|
|
@as(usize, 2),
|
|
(try selectEvents(&database, gpa, .{ .severity = severity_error })).items.len,
|
|
);
|
|
try testing.expectEqual(
|
|
@as(usize, 2),
|
|
(try selectEvents(&database, gpa, .{ .component = "query_log" })).items.len,
|
|
);
|
|
try testing.expectEqual(
|
|
@as(usize, 1),
|
|
(try selectEvents(&database, gpa, .{ .component = "query_log", .state = .active })).items.len,
|
|
);
|
|
// The component match is anchored at the dot: `query` is not `query_log`.
|
|
try testing.expectEqual(@as(usize, 0), (try selectEvents(&database, gpa, .{ .component = "query" })).items.len);
|
|
// And `_` is a literal, not the `LIKE` wildcard it would otherwise be.
|
|
try testing.expectEqual(@as(usize, 0), (try selectEvents(&database, gpa, .{ .component = "query%log" })).items.len);
|
|
try testing.expectEqual(@as(usize, 0), (try selectEvents(&database, gpa, .{ .component = "queryxlog" })).items.len);
|
|
}
|
|
|
|
test "the window selects episodes that overlap it and an empty window selects nothing" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
|
|
// Over before the window opens.
|
|
const before = try open(&database, 100, "disk.probe", "statvfs");
|
|
try testing.expect(try resolveActive(&database, before, 200));
|
|
// Resolved exactly at `since`: over by the time the window begins.
|
|
const boundary = try open(&database, 100, "disk.probe", "data_dir");
|
|
try testing.expect(try resolveActive(&database, boundary, 300));
|
|
// Straddles the window.
|
|
const straddles = try open(&database, 100, "disk.probe", "log_dir");
|
|
try testing.expect(try resolveActive(&database, straddles, 350));
|
|
// Opens inside it and is still going.
|
|
_ = try open(&database, 320, "disk.space", "data");
|
|
// Opens after it closes.
|
|
_ = try open(&database, 400, "clients.storage", "prune");
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena.deinit();
|
|
const gpa = arena.allocator();
|
|
|
|
const window = try selectEvents(&database, gpa, .{ .since = 300, .until = 400 });
|
|
try testing.expectEqual(@as(usize, 2), window.items.len);
|
|
try testing.expectEqualStrings("disk.space", window.items[0].code);
|
|
try testing.expectEqualStrings("log_dir", window.items[1].subject);
|
|
|
|
// `since >= until` describes no time at all.
|
|
try testing.expectEqual(@as(usize, 0), (try selectEvents(&database, gpa, .{ .since = 300, .until = 300 })).items.len);
|
|
try testing.expectEqual(@as(usize, 0), (try selectEvents(&database, gpa, .{ .since = 400, .until = 300 })).items.len);
|
|
// Each end on its own still bounds.
|
|
try testing.expectEqual(@as(usize, 3), (try selectEvents(&database, gpa, .{ .until = 300 })).items.len);
|
|
try testing.expectEqual(@as(usize, 3), (try selectEvents(&database, gpa, .{ .since = 300 })).items.len);
|
|
}
|
|
|
|
test "the keyset cursor walks the whole table without repeating a row" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
for (0..5) |i| {
|
|
var key_buf: [8]u8 = undefined;
|
|
const key = try std.fmt.bufPrint(&key_buf, "k{d}", .{i});
|
|
_ = try open(&database, 100, "disk.probe", key);
|
|
}
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena.deinit();
|
|
const gpa = arena.allocator();
|
|
|
|
var seen: usize = 0;
|
|
var before: ?i64 = null;
|
|
while (true) {
|
|
const page = try selectEvents(&database, gpa, .{ .limit = 2, .before = before });
|
|
if (page.items.len == 0) break;
|
|
for (page.items) |row| {
|
|
if (before) |cursor| try testing.expect(row.id < cursor);
|
|
seen += 1;
|
|
}
|
|
before = page.items[page.items.len - 1].id;
|
|
}
|
|
try testing.expectEqual(@as(usize, 5), seen);
|
|
}
|
|
|
|
test "the limit is capped at the repository's maximum" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
try seedPage(&database);
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena.deinit();
|
|
// A caller that forgets the cap cannot ask this connection for more than
|
|
// `max_limit` rows; with four seeded it can only be observed as "all four".
|
|
const rows = try selectEvents(&database, arena.allocator(), .{ .limit = std.math.maxInt(u32) });
|
|
try testing.expectEqual(@as(usize, 4), rows.items.len);
|
|
}
|
|
|
|
test "selectOne answers by id and reports an unknown one as null" {
|
|
var database = try openConfig();
|
|
defer database.close();
|
|
const id = try insertActive(&database, 100, "certificate.reload", "doh", "doh", severity_warning, "stat failed");
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena.deinit();
|
|
|
|
const row = (try selectOne(&database, arena.allocator(), id)).?;
|
|
try testing.expectEqualStrings("certificate.reload", row.code);
|
|
try testing.expectEqualStrings("certificate", row.component);
|
|
try testing.expectEqualStrings("stat failed", row.detail);
|
|
try testing.expectEqual(@as(i64, 1), row.occurrences);
|
|
try testing.expect((try selectOne(&database, arena.allocator(), id + 1)) == null);
|
|
}
|
|
|
|
test "componentOf takes the part before the first dot" {
|
|
try testing.expectEqualStrings("blocklist", componentOf("blocklist.refresh"));
|
|
try testing.expectEqualStrings("query_log", componentOf("query_log.write"));
|
|
// Not something `events.Code` can produce; it still gets an answer.
|
|
try testing.expectEqualStrings("bare", componentOf("bare"));
|
|
try testing.expectEqualStrings("", componentOf(""));
|
|
}
|