milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -547,7 +547,6 @@ fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8
|
||||
pub const StatsTotals = struct {
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
cached: u64,
|
||||
distinct_clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
};
|
||||
@@ -557,7 +556,6 @@ pub const StatsTotals = struct {
|
||||
const stats_totals_sql =
|
||||
\\SELECT count(*),
|
||||
\\ coalesce(sum(blocked <> 0), 0),
|
||||
\\ coalesce(sum(cache_hit = 1), 0),
|
||||
\\ count(DISTINCT client_ip),
|
||||
\\ coalesce(sum(response_time_us), 0),
|
||||
\\ count(response_time_us)
|
||||
@@ -577,13 +575,12 @@ pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotal
|
||||
// statement is not the one this function prepared.
|
||||
if (!try stmt.step()) return error.Misuse;
|
||||
|
||||
const timed = stmt.columnInt(5);
|
||||
const timed = stmt.columnInt(4);
|
||||
return .{
|
||||
.queries = try countOf(stmt.columnInt(0)),
|
||||
.blocked = try countOf(stmt.columnInt(1)),
|
||||
.cached = try countOf(stmt.columnInt(2)),
|
||||
.distinct_clients = try countOf(stmt.columnInt(3)),
|
||||
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed),
|
||||
.distinct_clients = try countOf(stmt.columnInt(2)),
|
||||
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(3), timed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -653,10 +650,217 @@ pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Buck
|
||||
return out.len;
|
||||
}
|
||||
|
||||
/// One row of `/api/stats/types`. `qtype` is nullable in the schema, so the
|
||||
/// rows that carry no type group into a row of their own rather than
|
||||
/// disappearing from a breakdown that claims to add up.
|
||||
///
|
||||
/// No name field: the only qtype-name table lives in the admin, and a second
|
||||
/// copy here would drift out of agreement with it.
|
||||
pub const TypeCount = struct {
|
||||
qtype: ?u16,
|
||||
count: u64,
|
||||
};
|
||||
|
||||
/// `qtype IS NULL` sorts 0 before 1, which puts the null row last within a tie.
|
||||
/// The ordering is total, so two reads of one window return the same list in
|
||||
/// the same order — which is what makes the goldens byte-stable. It is an
|
||||
/// order, not an identity: a caller keys on the `qtype` value, never on a row's
|
||||
/// position, because a rank change between refreshes moves rows and must not
|
||||
/// move what they mean.
|
||||
const stats_types_sql =
|
||||
\\SELECT qtype, count(*)
|
||||
\\ FROM query_log
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
\\ GROUP BY qtype
|
||||
\\ ORDER BY count(*) DESC, qtype IS NULL, qtype ASC
|
||||
;
|
||||
|
||||
/// The query-type breakdown of `[since, until)`. No zero rows: a type absent
|
||||
/// from the window is absent from the list.
|
||||
pub fn statsTypes(
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
since: i64,
|
||||
until: i64,
|
||||
) db.Error!std.ArrayList(TypeCount) {
|
||||
var out: std.ArrayList(TypeCount) = .empty;
|
||||
|
||||
var stmt = try database.prepare(stats_types_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, until);
|
||||
|
||||
while (try stmt.step()) {
|
||||
try out.append(arena, .{
|
||||
.qtype = if (stmt.isNull(0)) null else try columnU16(&stmt, 0),
|
||||
.count = try countOf(stmt.columnInt(1)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// One row of `/api/stats/routes`: how a slice of the window was answered.
|
||||
///
|
||||
/// `source` is the answering resolver's identity and nothing else — the
|
||||
/// upstream url for `upstream` rows, the zone for `forward_zone` rows, null
|
||||
/// everywhere else. It is deliberately not `source_name`, which names the
|
||||
/// blocklist a block came from and would read as an upstream here.
|
||||
pub const RouteCount = struct {
|
||||
route: provenance.RouteKind,
|
||||
source: ?[]const u8,
|
||||
count: u64,
|
||||
};
|
||||
|
||||
/// A null upstream on an `upstream` row is its own group, not a dropped row: it
|
||||
/// is a real state of the log and the caller labels it.
|
||||
const stats_routes_sql =
|
||||
\\SELECT route_kind,
|
||||
\\ CASE route_kind
|
||||
\\ WHEN 'upstream' THEN upstream
|
||||
\\ WHEN 'forward_zone' THEN forward_zone
|
||||
\\ END AS source,
|
||||
\\ count(*)
|
||||
\\ FROM query_log
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
\\ GROUP BY route_kind, source
|
||||
\\ ORDER BY count(*) DESC, route_kind ASC, source IS NULL, source ASC
|
||||
;
|
||||
|
||||
/// The answering-route breakdown of `[since, until)`. Strings are copied into
|
||||
/// `arena`, which outlives the statement.
|
||||
pub fn statsRoutes(
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
since: i64,
|
||||
until: i64,
|
||||
) db.Error!std.ArrayList(RouteCount) {
|
||||
var out: std.ArrayList(RouteCount) = .empty;
|
||||
|
||||
var stmt = try database.prepare(stats_routes_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, until);
|
||||
|
||||
while (try stmt.step()) {
|
||||
try out.append(arena, .{
|
||||
.route = try provenance.parse(provenance.RouteKind, stmt.columnText(0)),
|
||||
.source = try stmt.columnTextAllocOrNull(arena, 1),
|
||||
.count = try countOf(stmt.columnInt(2)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// How many clients `/api/stats/clients` names before the rest become `other`.
|
||||
/// Eight is what one legend can carry without becoming a second table.
|
||||
pub const max_client_series = 8;
|
||||
|
||||
/// One named client's series. `buckets` is always the caller's bucket count
|
||||
/// long, zero-filled, and aligned exactly like `timeseries`.
|
||||
pub const ClientSeries = struct {
|
||||
client: []const u8,
|
||||
buckets: []const u64,
|
||||
};
|
||||
|
||||
/// `other` is always present and always bucket-count sized — including for an
|
||||
/// empty window and for a window with eight clients or fewer. A caller charting
|
||||
/// a stack must not have to invent the residual series.
|
||||
pub const ClientsBreakdown = struct {
|
||||
clients: []const ClientSeries,
|
||||
other: []const u64,
|
||||
};
|
||||
|
||||
/// Ranked by in-window total, ties broken by address, so the cut at eight is
|
||||
/// the same cut on every request over the same data.
|
||||
const stats_clients_rank_sql =
|
||||
\\SELECT client_ip
|
||||
\\ FROM query_log
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
\\ GROUP BY client_ip
|
||||
\\ ORDER BY count(*) DESC, client_ip ASC
|
||||
\\ LIMIT ?3
|
||||
;
|
||||
|
||||
const stats_clients_buckets_sql =
|
||||
\\SELECT client_ip, (timestamp - ?1) / ?2, count(*)
|
||||
\\ FROM query_log
|
||||
\\ WHERE timestamp >= ?1 AND timestamp < ?3
|
||||
\\ GROUP BY 1, 2
|
||||
;
|
||||
|
||||
/// Per-client counts over `bucket_count` buckets of `bucket_seconds` starting
|
||||
/// at `since`. Everything outside the top `max_client_series` sums into
|
||||
/// `other`, so the series still add up to the window's total.
|
||||
///
|
||||
/// Two statements, one ranking and one bucketing: the caller runs them inside
|
||||
/// one read transaction, so the rank and the buckets describe one state.
|
||||
pub fn statsClients(
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
since: i64,
|
||||
bucket_seconds: u32,
|
||||
bucket_count: u32,
|
||||
) db.Error!ClientsBreakdown {
|
||||
if (bucket_seconds == 0 or bucket_count == 0) return error.Misuse;
|
||||
const width: i64 = bucket_seconds;
|
||||
const span = std.math.mul(i64, width, bucket_count) catch return error.Misuse;
|
||||
const until = std.math.add(i64, since, span) catch return error.Misuse;
|
||||
|
||||
var names: std.ArrayList([]const u8) = .empty;
|
||||
var series: std.ArrayList([]u64) = .empty;
|
||||
{
|
||||
var stmt = try database.prepare(stats_clients_rank_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, until);
|
||||
try stmt.bindInt(3, max_client_series);
|
||||
|
||||
while (try stmt.step()) {
|
||||
try names.append(arena, try stmt.columnTextAlloc(arena, 0));
|
||||
try series.append(arena, try zeroedBuckets(arena, bucket_count));
|
||||
}
|
||||
}
|
||||
|
||||
const other = try zeroedBuckets(arena, bucket_count);
|
||||
|
||||
var stmt = try database.prepare(stats_clients_buckets_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, since);
|
||||
try stmt.bindInt(2, width);
|
||||
try stmt.bindInt(3, until);
|
||||
|
||||
while (try stmt.step()) {
|
||||
// The WHERE clause bounds the index already; the check keeps a schema
|
||||
// surprise from writing past the slice.
|
||||
const index = std.math.cast(usize, stmt.columnInt(1)) orelse return error.Mismatch;
|
||||
if (index >= bucket_count) return error.Mismatch;
|
||||
const count = try countOf(stmt.columnInt(2));
|
||||
|
||||
const client = stmt.columnText(0);
|
||||
const target = for (names.items, series.items) |name_, buckets| {
|
||||
if (std.mem.eql(u8, name_, client)) break buckets;
|
||||
} else other;
|
||||
target[index] += count;
|
||||
}
|
||||
|
||||
const clients = try arena.alloc(ClientSeries, names.items.len);
|
||||
for (clients, names.items, series.items) |*entry, name_, buckets| {
|
||||
entry.* = .{ .client = name_, .buckets = buckets };
|
||||
}
|
||||
return .{ .clients = clients, .other = other };
|
||||
}
|
||||
|
||||
fn zeroedBuckets(arena: Allocator, bucket_count: u32) Allocator.Error![]u64 {
|
||||
const buckets = try arena.alloc(u64, bucket_count);
|
||||
@memset(buckets, 0);
|
||||
return buckets;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const logger = @import("../logger.zig");
|
||||
const querylog_schema = @import("../querylog_schema.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
@@ -1469,7 +1673,6 @@ test "statsTotals aggregates the window and averages only the timed rows" {
|
||||
const totals = try statsTotals(&database, 100, 200);
|
||||
try testing.expectEqual(@as(u64, 3), totals.queries);
|
||||
try testing.expectEqual(@as(u64, 1), totals.blocked);
|
||||
try testing.expectEqual(@as(u64, 1), totals.cached);
|
||||
try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
|
||||
// (100 + 200) / 2 — the untimed row is not in the divisor.
|
||||
try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
|
||||
@@ -1484,7 +1687,6 @@ test "statsTotals over an empty window is zeros with a null average" {
|
||||
const totals = try statsTotals(&database, window[0], window[1]);
|
||||
try testing.expectEqual(@as(u64, 0), totals.queries);
|
||||
try testing.expectEqual(@as(u64, 0), totals.blocked);
|
||||
try testing.expectEqual(@as(u64, 0), totals.cached);
|
||||
try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
|
||||
try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
|
||||
}
|
||||
@@ -1588,3 +1790,334 @@ test "likePattern wraps the needle and neutralises every metacharacter" {
|
||||
try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b"));
|
||||
try testing.expectEqualStrings("%%", try likePattern(arena, ""));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// period aggregations (milestone 30)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const agg_since: i64 = 1_700_000_000;
|
||||
const agg_width: u32 = 60;
|
||||
const agg_buckets: u32 = 10;
|
||||
const agg_until: i64 = agg_since + agg_width * agg_buckets;
|
||||
|
||||
/// One row of the aggregation fixtures. Everything the three breakdowns read
|
||||
/// is a parameter; everything else is the same on every row, so a test that
|
||||
/// changes an outcome names the reason it changed.
|
||||
fn aggRow(offset: i64, client: []const u8, qtype: ?u16, kind: provenance.RouteKind, source: ?[]const u8) Row {
|
||||
return .{
|
||||
.timestamp = agg_since + offset,
|
||||
.domain = "example.com",
|
||||
.client_ip = client,
|
||||
.qtype = qtype,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = kind == .blocked,
|
||||
.response_time_us = 1000,
|
||||
.cache_hit = kind == .cache,
|
||||
.upstream = if (kind == .upstream) source else null,
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = if (kind == .blocked) .block else .allow,
|
||||
.policy_reason = if (kind == .blocked) .blocklist_domain else .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
// Blocklist provenance, deliberately set on every row: the routes
|
||||
// breakdown must never group by it.
|
||||
.source_name = "StevenBlack",
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = kind,
|
||||
.forward_zone = if (kind == .forward_zone) source else null,
|
||||
};
|
||||
}
|
||||
|
||||
test "the type breakdown groups by qtype, keeps the null row and orders it last" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try seed(&database, &.{
|
||||
aggRow(0, "192.0.2.10", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(1, "192.0.2.10", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(2, "192.0.2.10", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(3, "192.0.2.10", 28, .upstream, "9.9.9.9"),
|
||||
aggRow(4, "192.0.2.10", 28, .upstream, "9.9.9.9"),
|
||||
// Ties with qtype 28, so the tie-break puts the lower code first.
|
||||
aggRow(5, "192.0.2.10", 16, .upstream, "9.9.9.9"),
|
||||
aggRow(6, "192.0.2.10", 16, .upstream, "9.9.9.9"),
|
||||
// A row with no type at all: its own group, never a dropped row.
|
||||
aggRow(7, "192.0.2.10", null, .upstream, "9.9.9.9"),
|
||||
aggRow(8, "192.0.2.10", null, .upstream, "9.9.9.9"),
|
||||
// Outside the window.
|
||||
aggRow(-1, "192.0.2.10", 255, .upstream, "9.9.9.9"),
|
||||
});
|
||||
|
||||
const rows = (try statsTypes(&database, arena, agg_since, agg_until)).items;
|
||||
try testing.expectEqual(@as(usize, 4), rows.len);
|
||||
try testing.expectEqual(@as(?u16, 1), rows[0].qtype);
|
||||
try testing.expectEqual(@as(u64, 3), rows[0].count);
|
||||
try testing.expectEqual(@as(?u16, 16), rows[1].qtype);
|
||||
try testing.expectEqual(@as(?u16, 28), rows[2].qtype);
|
||||
try testing.expectEqual(@as(?u16, null), rows[3].qtype);
|
||||
try testing.expectEqual(@as(u64, 2), rows[3].count);
|
||||
}
|
||||
|
||||
test "an empty window has no type rows at all" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const rows = (try statsTypes(&database, arena_state.allocator(), agg_since, agg_until)).items;
|
||||
try testing.expectEqual(@as(usize, 0), rows.len);
|
||||
}
|
||||
|
||||
test "the route breakdown keys on the answering resolver, not on blocklist provenance" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try seed(&database, &.{
|
||||
aggRow(0, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"),
|
||||
aggRow(1, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"),
|
||||
aggRow(2, "192.0.2.10", 1, .upstream, "https://b.example/dns-query"),
|
||||
// An upstream row whose resolver the log did not record: its own group.
|
||||
aggRow(3, "192.0.2.10", 1, .upstream, null),
|
||||
aggRow(4, "192.0.2.10", 1, .forward_zone, "lan"),
|
||||
aggRow(5, "192.0.2.10", 1, .blocked, null),
|
||||
aggRow(6, "192.0.2.10", 1, .cache, null),
|
||||
aggRow(7, "192.0.2.10", 1, .local, null),
|
||||
aggRow(8, "192.0.2.10", 1, .rejected, null),
|
||||
});
|
||||
|
||||
const rows = (try statsRoutes(&database, arena, agg_since, agg_until)).items;
|
||||
// Two upstreams, one null-source upstream, one forward zone and four
|
||||
// source-less kinds. Every row carries the same `source_name`, so a
|
||||
// breakdown that grouped by it would collapse to one row.
|
||||
try testing.expectEqual(@as(usize, 8), rows.len);
|
||||
|
||||
try testing.expectEqual(provenance.RouteKind.upstream, rows[0].route);
|
||||
try testing.expectEqualStrings("https://a.example/dns-query", rows[0].source.?);
|
||||
try testing.expectEqual(@as(u64, 2), rows[0].count);
|
||||
|
||||
// The seven remaining rows all count 1, so the tie-break orders them:
|
||||
// route ascending, then source ascending with nulls last.
|
||||
for (rows[1..]) |row| try testing.expectEqual(@as(u64, 1), row.count);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, rows[1].route);
|
||||
try testing.expectEqual(@as(?[]const u8, null), rows[1].source);
|
||||
try testing.expectEqual(provenance.RouteKind.cache, rows[2].route);
|
||||
try testing.expectEqual(provenance.RouteKind.forward_zone, rows[3].route);
|
||||
try testing.expectEqualStrings("lan", rows[3].source.?);
|
||||
try testing.expectEqual(provenance.RouteKind.local, rows[4].route);
|
||||
try testing.expectEqual(provenance.RouteKind.rejected, rows[5].route);
|
||||
try testing.expectEqual(provenance.RouteKind.upstream, rows[6].route);
|
||||
try testing.expectEqualStrings("https://b.example/dns-query", rows[6].source.?);
|
||||
try testing.expectEqual(provenance.RouteKind.upstream, rows[7].route);
|
||||
try testing.expectEqual(@as(?[]const u8, null), rows[7].source);
|
||||
}
|
||||
|
||||
test "an empty window has no route rows at all" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const rows = (try statsRoutes(&database, arena_state.allocator(), agg_since, agg_until)).items;
|
||||
try testing.expectEqual(@as(usize, 0), rows.len);
|
||||
}
|
||||
|
||||
test "an empty window still has a zero-filled other series and no named clients" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
|
||||
try testing.expectEqual(@as(usize, 0), result.clients.len);
|
||||
try testing.expectEqual(@as(usize, agg_buckets), result.other.len);
|
||||
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
|
||||
}
|
||||
|
||||
test "client series are bucket-aligned, zero-filled and ranked by in-window total" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try seed(&database, &.{
|
||||
aggRow(0, "192.0.2.20", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(1, "192.0.2.20", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(agg_width * 3, "192.0.2.20", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(agg_width * 3, "192.0.2.10", 1, .upstream, "9.9.9.9"),
|
||||
// Outside the window on both sides.
|
||||
aggRow(-1, "192.0.2.20", 1, .upstream, "9.9.9.9"),
|
||||
aggRow(agg_width * agg_buckets, "192.0.2.10", 1, .upstream, "9.9.9.9"),
|
||||
});
|
||||
|
||||
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
|
||||
try testing.expectEqual(@as(usize, 2), result.clients.len);
|
||||
try testing.expectEqualStrings("192.0.2.20", result.clients[0].client);
|
||||
try testing.expectEqualStrings("192.0.2.10", result.clients[1].client);
|
||||
|
||||
for (result.clients) |series| try testing.expectEqual(@as(usize, agg_buckets), series.buckets.len);
|
||||
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
|
||||
try testing.expectEqual(@as(u64, 1), result.clients[0].buckets[3]);
|
||||
try testing.expectEqual(@as(u64, 0), result.clients[0].buckets[9]);
|
||||
try testing.expectEqual(@as(u64, 1), result.clients[1].buckets[3]);
|
||||
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
|
||||
}
|
||||
|
||||
test "the ninth client folds into other and the cut is the same on every read" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// Nine clients, each with one more query than the next, so the ranking is
|
||||
// total and the ninth is unambiguously the one that folds.
|
||||
var address: [16]u8 = undefined;
|
||||
var index: u32 = 0;
|
||||
while (index < 9) : (index += 1) {
|
||||
const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index});
|
||||
var repeat: u32 = 0;
|
||||
while (repeat <= index) : (repeat += 1) {
|
||||
try seed(&database, &.{aggRow(@intCast(repeat), client, 1, .upstream, "9.9.9.9")});
|
||||
}
|
||||
}
|
||||
|
||||
const result = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
|
||||
try testing.expectEqual(@as(usize, max_client_series), result.clients.len);
|
||||
// The busiest is 192.0.2.108 with nine rows; the lone folded client is
|
||||
// 192.0.2.100 with one.
|
||||
try testing.expectEqualStrings("192.0.2.108", result.clients[0].client);
|
||||
for (result.clients) |series| {
|
||||
try testing.expect(!std.mem.eql(u8, "192.0.2.100", series.client));
|
||||
}
|
||||
|
||||
var other_total: u64 = 0;
|
||||
for (result.other) |count| other_total += count;
|
||||
try testing.expectEqual(@as(u64, 1), other_total);
|
||||
}
|
||||
|
||||
test "the three breakdowns conserve the window's total" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// A matrix that exercises every branch the aggregations partition on: null
|
||||
// and non-null qtypes, every route kind, a null resolver identity, and ten
|
||||
// clients so the top-eight cut has a residual to carry.
|
||||
const kinds = [_]provenance.RouteKind{ .blocked, .local, .forward_zone, .upstream, .cache, .rejected };
|
||||
var address: [16]u8 = undefined;
|
||||
var index: u32 = 0;
|
||||
while (index < 40) : (index += 1) {
|
||||
const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index % 10});
|
||||
const kind = kinds[index % kinds.len];
|
||||
try seed(&database, &.{aggRow(
|
||||
@intCast(index % (agg_width * agg_buckets)),
|
||||
client,
|
||||
if (index % 7 == 0) null else @intCast(1 + index % 3),
|
||||
kind,
|
||||
if (index % 11 == 0) null else "9.9.9.9",
|
||||
)});
|
||||
}
|
||||
|
||||
const totals = try statsTotals(&database, agg_since, agg_until);
|
||||
try testing.expect(totals.queries > 0);
|
||||
|
||||
var typed: u64 = 0;
|
||||
for ((try statsTypes(&database, arena, agg_since, agg_until)).items) |row| typed += row.count;
|
||||
try testing.expectEqual(totals.queries, typed);
|
||||
|
||||
var routed: u64 = 0;
|
||||
for ((try statsRoutes(&database, arena, agg_since, agg_until)).items) |row| routed += row.count;
|
||||
try testing.expectEqual(totals.queries, routed);
|
||||
|
||||
var buckets: [agg_buckets]Bucket = undefined;
|
||||
_ = try timeseries(&database, agg_since, agg_width, &buckets);
|
||||
const clients = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
|
||||
// Per bucket, not just in total: a series misaligned by one bucket would
|
||||
// still sum correctly over the window.
|
||||
for (buckets, 0..) |bucket, at| {
|
||||
var summed: u64 = clients.other[at];
|
||||
for (clients.clients) |series| summed += series.buckets[at];
|
||||
try testing.expectEqual(bucket.queries, summed);
|
||||
}
|
||||
}
|
||||
|
||||
test "the aggregations pass a redacted client through as the log stored it" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
// `hide_client_ips` is applied by the logger before the row is written, so
|
||||
// the read path has nothing to transform — and must not invent anything
|
||||
// either. The marker is the client's whole identity here.
|
||||
try seed(&database, &.{
|
||||
aggRow(0, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
|
||||
aggRow(1, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
|
||||
});
|
||||
|
||||
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
|
||||
try testing.expectEqual(@as(usize, 1), result.clients.len);
|
||||
try testing.expectEqualStrings(logger.hidden_marker, result.clients[0].client);
|
||||
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
|
||||
}
|
||||
|
||||
test "a prune committed mid-read is invisible to the reader's transaction" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/querylog.db", .{ tmp_prefix, &tmp.sub_path });
|
||||
|
||||
var reader = try db.Db.open(path, .{ .mode = .read_write_create });
|
||||
defer reader.close();
|
||||
try db.applyPragmas(&reader, .{});
|
||||
try reader.exec(querylog_schema.ddl);
|
||||
// The seeded watermark is the file's creation second, which is now; the
|
||||
// fixtures below are in 2023, so the prune's cutoff would never advance it.
|
||||
try reader.exec(
|
||||
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
|
||||
);
|
||||
try seed(&reader, &.{
|
||||
plainRow(agg_since, "a.example"),
|
||||
plainRow(agg_since + 1, "b.example"),
|
||||
plainRow(agg_since + 300, "c.example"),
|
||||
});
|
||||
|
||||
// A second connection to the same file, as retention has in production.
|
||||
var pruner = try db.Db.open(path, .{ .mode = .read_write_create });
|
||||
defer pruner.close();
|
||||
try db.applyPragmas(&pruner, .{});
|
||||
|
||||
var tx = try db.ReadTx.begin(&reader);
|
||||
const before = try statsTotals(&reader, agg_since, agg_since + 1000);
|
||||
const watermark_before = try availableSince(&reader);
|
||||
try testing.expectEqual(@as(u64, 3), before.queries);
|
||||
|
||||
// The prune commits while the reader's transaction is open.
|
||||
const pruned = try pruneOlderThan(&pruner, agg_since + 200);
|
||||
try testing.expectEqual(@as(i64, 2), pruned.deleted);
|
||||
try testing.expect(pruned.available_since > watermark_before);
|
||||
|
||||
// Neither half of the answer moved: the rows the reader would report and
|
||||
// the watermark it would tag them with still describe one state.
|
||||
const during = try statsTotals(&reader, agg_since, agg_since + 1000);
|
||||
try testing.expectEqual(before.queries, during.queries);
|
||||
try testing.expectEqual(watermark_before, try availableSince(&reader));
|
||||
try tx.commit();
|
||||
|
||||
// The next response sees the prune — both halves of it.
|
||||
const after = try statsTotals(&reader, agg_since, agg_since + 1000);
|
||||
try testing.expectEqual(@as(u64, 1), after.queries);
|
||||
try testing.expectEqual(pruned.available_since, try availableSince(&reader));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user