//! `GET /api/overview`: everything the Overview page draws, for one period, in //! one response. //! //! It replaces the five per-panel endpoints milestone 30 shipped. Those cost //! five scans of every raw row in the window and, being five requests, could //! only promise a shared *window* — queries logged between two of them moved //! one panel and not the other. One request over one read transaction promises //! a shared *snapshot*: the totals, the four breakdowns and the coverage //! watermark beside them all describe one database state, so the breakdowns sum //! to the totals for a reason and not by luck. //! //! Buckets are aligned to the UTC grid, not to the moment of the request. Every //! width divides a day, so flooring the current time to a multiple of the width //! puts each bucket on the same boundary a human reads off a clock, and two //! requests a second apart return the same bucket starts. The last bucket is //! the one in progress; it fills as the period runs. //! //! The aggregate runs on the web task's own query-log connection (m7 ruling //! 21), which every connection task shares. SQLite's serialized mode makes one //! call safe; it does not make a transaction safe, so `WebState.querylog_lock` //! covers the whole read and a second BEGIN can never land inside the first. //! The transaction is deferred, not `db.Tx`'s BEGIN IMMEDIATE, which would //! stall the logger and retention behind an HTTP response. //! //! The lock is released before the response is written: the body is already //! built in the request arena, and holding a database lock across a socket //! write would let one slow client serialize every other reader. const std = @import("std"); const coverage = @import("../coverage.zig"); const db = @import("../../storage/db.zig"); const http_util = @import("../http_util.zig"); const queries_repo = @import("../../storage/repositories/queries_repo.zig"); const server = @import("../server.zig"); const log = std.log.scoped(.web_overview); /// The four periods ruling 13 defines. The tag names are the wire spellings. pub const Period = enum { @"1h", @"24h", @"7d", @"30d", pub fn parse(text: []const u8) ?Period { return std.meta.stringToEnum(Period, text); } /// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h. pub fn bucketSeconds(self: Period) u32 { return switch (self) { .@"1h" => 60, .@"24h" => 30 * 60, .@"7d" => 60 * 60, .@"30d" => 6 * 60 * 60, }; } pub fn bucketCount(self: Period) u32 { return switch (self) { .@"1h" => 60, .@"24h" => 48, .@"7d" => 168, .@"30d" => 120, }; } pub fn label(self: Period) []const u8 { return @tagName(self); } }; pub const default_period: Period = .@"24h"; /// The widest period's bucket count. Nothing here allocates by it any more — /// the repository returns arena slices — but it is the bound the response size /// argument rests on, and the assertion below is what keeps it true. pub const max_buckets = 168; comptime { std.debug.assert(std.enums.values(Period).len == server.OverviewCache.slot_count); for (std.enums.values(Period)) |period| { std.debug.assert(period.bucketCount() <= max_buckets); // The UTC alignment argument holds only while every width divides a day. std.debug.assert(86_400 % period.bucketSeconds() == 0); } } pub const Window = struct { /// Inclusive, on the bucket grid. since: i64, /// Exclusive: the end of the bucket that `now` falls in. until: i64, bucket_seconds: u32, bucket_count: u32, }; pub fn window(period: Period, now_unix: i64) Window { const width: i64 = period.bucketSeconds(); const count: i64 = period.bucketCount(); const until = @divFloor(now_unix, width) * width + width; return .{ .since = until - width * count, .until = until, .bucket_seconds = period.bucketSeconds(), .bucket_count = period.bucketCount(), }; } pub const Totals = struct { queries: u64, blocked: u64, /// Distinct client addresses in the window. clients: u64, avg_response_time_us: ?i64, }; pub const Body = struct { period: []const u8, since: i64, until: i64, bucket_seconds: u32, totals: Totals, buckets: []const queries_repo.Bucket, clients: []const queries_repo.ClientSeries, other: []const u64, types: []const queries_repo.TypeCount, routes: []const queries_repo.RouteCount, /// Judged against `since`, which is the window this body reports on — so a /// dashboard can say "history starts here" instead of charting a pruned /// stretch as a quiet one. coverage: coverage.Coverage, }; pub fn handle( state: *server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { const period = periodParam(request.query) catch return badPeriod(request); const database = state.querylog_db orelse return unavailable(request); const span = window(period, std.Io.Clock.real.now(io).toSeconds()); const body = cachedBody(state, io, database, request.arena, period, span) catch |err| { return internal(request, err); }; return http_util.respondBytes(request, .ok, body, http_util.content_type_json, &.{}); } /// The whole cache decision, start to finish, under one hold of /// `querylog_lock`. Returns bytes owned by `arena`, so the caller writes the /// socket with the lock already released. /// /// `data_version` is sampled inside the lock and the rebuild is published under /// that same sample: a commit landing on another connection while this task /// builds moves the pragma, so the entry it installs is keyed to a version the /// next request will not ask for and that request rebuilds. Stale bytes under a /// current key are therefore not reachable. A failed build or a failed commit /// publishes nothing and leaves whatever the slot already held. fn cachedBody( state: *server.WebState, io: std.Io, database: *db.Db, arena: std.mem.Allocator, period: Period, span: Window, ) db.Error![]const u8 { state.querylog_lock.lockUncancelable(io); defer state.querylog_lock.unlock(io); const index = @intFromEnum(period); const data_version = try database.queryInt("PRAGMA data_version"); if (state.overview_cache.get(index, span.until, data_version)) |cached| { // The copy is what makes a later rebuild's free-and-replace safe: the // response is written after the lock is gone, and by then these bytes // may belong to nobody. return arena.dupe(u8, cached); } const body = try buildBody(state, io, database, arena, period, span); const owned = try state.gpa.dupe(u8, body); state.overview_cache.put(state.gpa, index, span.until, data_version, owned); return body; } /// One read transaction, one snapshot, one serialized body in `arena`. The /// caller holds `querylog_lock` and keeps holding it. fn buildBody( state: *server.WebState, io: std.Io, database: *db.Db, arena: std.mem.Allocator, period: Period, span: Window, ) db.Error![]const u8 { var scope = try server.QuerylogRead.openLocked(state, io, database); errdefer scope.abort(); const data = try queries_repo.overview( database, arena, span.since, span.bucket_seconds, span.bucket_count, ); const window_coverage = try coverage.read(database, span.since); try scope.commit(); var allocating: std.Io.Writer.Allocating = .init(arena); errdefer allocating.deinit(); std.json.Stringify.value(Body{ .period = period.label(), .since = span.since, .until = span.until, .bucket_seconds = span.bucket_seconds, .totals = .{ .queries = data.totals.queries, .blocked = data.totals.blocked, .clients = data.totals.distinct_clients, .avg_response_time_us = data.totals.avg_response_time_us, }, .buckets = data.buckets, .clients = data.clients.clients, .other = data.clients.other, .types = data.types, .routes = data.routes, .coverage = window_coverage, }, .{}, &allocating.writer) catch return error.OutOfMemory; return allocating.written(); } pub const PeriodError = error{BadPeriod}; /// An absent `period` is the default; anything else it cannot read is a 400, /// never a silent fallback — a typo must not return a window nobody asked for. fn periodParam(query: []const u8) PeriodError!Period { var buf: [8]u8 = undefined; const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod; const text = found orelse return default_period; return Period.parse(text) orelse error.BadPeriod; } fn badPeriod(request: *http_util.Request) http_util.HandlerError!void { return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d"); } fn unavailable(request: *http_util.Request) http_util.HandlerError!void { return http_util.respondError(request, .service_unavailable, "query log unavailable"); } /// The one thing this file logs. A failed aggregate is a fault in the box, not /// a property of the request, and the client is told nothing beyond "internal /// error" (ruling 8, PLAN §19). fn internal(request: *http_util.Request, err: db.Error) http_util.HandlerError!void { log.warn("overview failed: {s}", .{@errorName(err)}); return http_util.respondError(request, .internal_server_error, "internal error"); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const querylog_schema = @import("../../storage/querylog_schema.zig"); const testing = std.testing; test "the period grammar accepts exactly the four spellings" { try testing.expectEqual(Period.@"1h", Period.parse("1h").?); try testing.expectEqual(Period.@"24h", Period.parse("24h").?); try testing.expectEqual(Period.@"7d", Period.parse("7d").?); try testing.expectEqual(Period.@"30d", Period.parse("30d").?); try testing.expectEqual(@as(?Period, null), Period.parse("12h")); try testing.expectEqual(@as(?Period, null), Period.parse("1H")); try testing.expectEqual(@as(?Period, null), Period.parse("")); } test "an absent period defaults and a bad one is rejected" { try testing.expectEqual(default_period, try periodParam("")); try testing.expectEqual(default_period, try periodParam("limit=5")); try testing.expectEqual(Period.@"7d", try periodParam("period=7d")); try testing.expectError(error.BadPeriod, periodParam("period=12h")); try testing.expectError(error.BadPeriod, periodParam("period=%2")); // Longer than any spelling: rejected rather than truncated to "1h". try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh")); } test "each period spans its own bucket width times its count" { for (std.enums.values(Period)) |period| { const span = window(period, 1_700_000_000); const width: i64 = period.bucketSeconds(); try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since); } } test "the window sits on the UTC grid and ends with the bucket in progress" { // 2023-11-14T22:13:20Z, which is not on any bucket boundary. const now: i64 = 1_700_000_000; const span = window(.@"24h", now); try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800)); try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800)); try testing.expect(span.until > now); try testing.expect(span.until - now <= 1800); try testing.expectEqual(@as(u32, 48), span.bucket_count); } test "two requests inside one bucket see the same window" { // A bucket boundary, so the offsets below stay inside one minute. const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60); const first = window(.@"1h", boundary); const second = window(.@"1h", boundary + 59); try testing.expectEqual(first.since, second.since); try testing.expectEqual(first.until, second.until); const next = window(.@"1h", boundary + 60); try testing.expectEqual(first.until + 60, next.until); } test "a timestamp exactly on a boundary starts a new bucket" { const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600); try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600)); try testing.expectEqual(@as(u32, 168), span.bucket_count); } test "every period's window is a window the repository will serve" { // The projection path needs the window start on the 30-minute grid and the // width a whole number of grains; `window` is the only producer of either. for (std.enums.values(Period)) |period| { const span = window(period, 1_700_000_123); if (span.bucket_seconds < 1800) continue; try testing.expectEqual(@as(i64, 0), @mod(span.since, 1800)); try testing.expectEqual(@as(u32, 0), span.bucket_seconds % 1800); } } 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; } fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void { const rows = [_]queries_repo.Row{.{ .timestamp = timestamp, .domain = "example.com", .client_ip = "192.0.2.10", .qtype = 1, .qclass = 1, .rcode = 0, .blocked = blocked, .response_time_us = 1000, .cache_hit = cached, .upstream = null, .group_id = 1, .group_name = "default", .policy_action = if (blocked) .block else .allow, .policy_reason = if (blocked) .blocklist_domain else .no_match, .matched = null, .source_id = null, .source_name = null, .cname_target = null, .safe_search_target = null, .route_kind = if (blocked) .blocked else .upstream, .forward_zone = null, }}; try writer.writeBatch(&rows); } test "one overview answers totals and buckets that agree over the same window" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const span = window(.@"1h", 1_700_000_000); var writer = try queries_repo.BatchWriter.init(testing.allocator, &database); defer writer.deinit(); // One row in the first bucket, two in the last, one just outside. try writeRow(&writer, span.since, false, false); try writeRow(&writer, span.until - 1, true, false); try writeRow(&writer, span.until - 2, false, true); try writeRow(&writer, span.since - 1, false, false); const data = try queries_repo.overview( &database, arena_state.allocator(), span.since, span.bucket_seconds, span.bucket_count, ); try testing.expectEqual(@as(u64, 3), data.totals.queries); try testing.expectEqual(@as(u64, 1), data.totals.blocked); try testing.expectEqual(@as(u64, 1), data.totals.distinct_clients); try testing.expectEqual(@as(?i64, 1000), data.totals.avg_response_time_us); try testing.expectEqual(@as(usize, 60), data.buckets.len); var summed: u64 = 0; var blocked: u64 = 0; for (data.buckets) |bucket| { summed += bucket.queries; blocked += bucket.blocked; } try testing.expectEqual(data.totals.queries, summed); try testing.expectEqual(data.totals.blocked, blocked); try testing.expectEqual(span.since, data.buckets[0].ts); try testing.expectEqual(@as(u64, 1), data.buckets[0].queries); try testing.expectEqual(@as(u64, 2), data.buckets[59].queries); try testing.expectEqual(span.until - span.bucket_seconds, data.buckets[59].ts); } test "an empty window reports zeros with a null mean" { var database = try openLog(); defer database.close(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const span = window(.@"30d", 1_700_000_000); const data = try queries_repo.overview( &database, arena_state.allocator(), span.since, span.bucket_seconds, span.bucket_count, ); try testing.expectEqual(@as(u64, 0), data.totals.queries); try testing.expectEqual(@as(?i64, null), data.totals.avg_response_time_us); try testing.expectEqual(@as(usize, 120), data.buckets.len); for (data.buckets) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries); // `other` is bucket-count sized even here: a chart must never have to // invent the residual series. try testing.expectEqual(@as(usize, 120), data.clients.other.len); } test "the cache serves one period's bytes and rebuilds when the key moves" { const gpa = testing.allocator; var cache: server.OverviewCache = .{}; defer cache.deinit(gpa); try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7)); cache.put(gpa, 0, 100, 7, try gpa.dupe(u8, "first")); try testing.expectEqualStrings("first", cache.get(0, 100, 7).?); // A different period, a rolled window and a bumped data version are three // different keys, and none of them hits. try testing.expectEqual(@as(?[]const u8, null), cache.get(1, 100, 7)); try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 101, 7)); try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 8)); // A rebuild replaces the entry and frees the old body; the leak checker in // `testing.allocator` is the assertion. cache.put(gpa, 0, 100, 8, try gpa.dupe(u8, "second")); try testing.expectEqualStrings("second", cache.get(0, 100, 8).?); } /// Two connections onto one file, which is the only arrangement in which /// `PRAGMA data_version` moves at all: it reports commits by *other* /// connections, so an in-memory database — where there is no other connection — /// could never witness the invalidation these tests are about. const CacheFixture = struct { threaded: std.Io.Threaded, tmp: std.testing.TmpDir, /// The web task's connection, the one the cache is keyed on. reader: db.Db, /// Stands in for the logger and for retention. writer: db.Db, state: server.WebState, arena_state: std.heap.ArenaAllocator, fn init(self: *CacheFixture, gpa: std.mem.Allocator) !void { self.threaded = .init(gpa, .{}); errdefer self.threaded.deinit(); self.tmp = std.testing.tmpDir(.{}); errdefer self.tmp.cleanup(); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrintZ( &path_buf, ".zig-cache/tmp/{s}/querylog.db", .{self.tmp.sub_path}, ); self.writer = try db.Db.open(path, .{ .mode = .read_write_create }); errdefer self.writer.close(); try db.applyPragmas(&self.writer, .{}); try self.writer.exec(querylog_schema.ddl); // The DDL stamps `created_at` from the wall clock, and the watermark // with it. These tests work over a fixed 2023 window, so a 2026 // watermark would report every one of them as uncovered and the prune // below would not move it. try self.writer.exec( "UPDATE querylog_meta SET created_at = 1600000000, available_since = 1600000000 WHERE id = 1", ); self.reader = try db.Db.open(path, .{ .mode = .read_write_existing }); errdefer self.reader.close(); try db.applyPragmas(&self.reader, .{}); self.state = .{ .gpa = gpa, .querylog_db = &self.reader }; self.arena_state = .init(gpa); } fn deinit(self: *CacheFixture) void { self.arena_state.deinit(); self.state.overview_cache.deinit(self.state.gpa); self.reader.close(); self.writer.close(); self.tmp.cleanup(); self.threaded.deinit(); } fn io(self: *CacheFixture) std.Io { return self.threaded.io(); } fn body(self: *CacheFixture, period: Period, span: Window) db.Error![]const u8 { return cachedBody( &self.state, self.io(), &self.reader, self.arena_state.allocator(), period, span, ); } /// One row through the writer connection, which commits and so moves the /// reader's `PRAGMA data_version`. fn log(self: *CacheFixture, timestamp: i64) !void { var batch = try queries_repo.BatchWriter.init(self.state.gpa, &self.writer); defer batch.deinit(); try writeRow(&batch, timestamp, false, false); } }; test "a cache hit answers without opening a read transaction" { var fx: CacheFixture = undefined; try fx.init(testing.allocator); defer fx.deinit(); const span = window(.@"1h", 1_700_000_000); try fx.log(span.since + 10); const first = try fx.body(.@"1h", span); try testing.expect(first.len > 0); // A hit never reaches the database, so a fault armed on the next commit is // never spent — and the bytes are the stored ones, not a rebuild's. db.read_tx_faults.failNextCommit(); const second = try fx.body(.@"1h", span); try testing.expectEqualStrings(first, second); try testing.expect(first.ptr != second.ptr); // Spend the armed fault so it cannot leak into a later test. A miss does // reach the database, so this one trips. db.read_tx_faults.beginCapture(); defer _ = db.read_tx_faults.endCapture(); try testing.expectError(error.Internal, fx.body(.@"24h", window(.@"24h", 1_700_000_000))); } test "a commit on another connection invalidates the cached body" { var fx: CacheFixture = undefined; try fx.init(testing.allocator); defer fx.deinit(); const span = window(.@"1h", 1_700_000_000); try fx.log(span.since + 10); const before = try fx.body(.@"1h", span); try fx.log(span.since + 20); const after = try fx.body(.@"1h", span); try testing.expect(!std.mem.eql(u8, before, after)); try testing.expect(std.mem.containsAtLeast(u8, after, 1, "\"queries\":2")); } test "a retention prune through another connection replaces the body and the watermark" { var fx: CacheFixture = undefined; try fx.init(testing.allocator); defer fx.deinit(); const span = window(.@"1h", 1_700_000_000); try fx.log(span.since + 10); const before = try fx.body(.@"1h", span); try testing.expect(std.mem.containsAtLeast(u8, before, 1, "\"queries\":1")); // Past the whole window: the row goes and the watermark advances, and both // halves of the response must move together. _ = try queries_repo.pruneOlderThan(&fx.writer, span.until); const after = try fx.body(.@"1h", span); try testing.expect(std.mem.containsAtLeast(u8, after, 1, "\"queries\":0")); var watermark_buf: [64]u8 = undefined; const watermark = try std.fmt.bufPrint( &watermark_buf, "\"available_since\":{d}", .{span.until}, ); try testing.expect(std.mem.containsAtLeast(u8, after, 1, watermark)); } test "a window roll rebuilds even with the data unchanged" { var fx: CacheFixture = undefined; try fx.init(testing.allocator); defer fx.deinit(); const now: i64 = 1_700_000_000; const first = try fx.body(.@"1h", window(.@"1h", now)); // One bucket later: same data, a different window, and so a different body. const rolled = try fx.body(.@"1h", window(.@"1h", now + 60)); try testing.expect(!std.mem.eql(u8, first, rolled)); // The slot now holds the rolled window; asking for the earlier one again // rebuilds rather than answering from a key that no longer matches. const again = try fx.body(.@"1h", window(.@"1h", now)); try testing.expectEqualStrings(first, again); } test "a failed commit installs nothing and leaves the stored entry alone" { var fx: CacheFixture = undefined; try fx.init(testing.allocator); defer fx.deinit(); const span = window(.@"1h", 1_700_000_000); try fx.log(span.since + 10); const stored = try fx.body(.@"1h", span); // A commit that fails on a rebuild: the key has moved, so this is a miss. try fx.log(span.since + 20); db.read_tx_faults.failNextCommit(); db.read_tx_faults.beginCapture(); try testing.expectError(error.Internal, fx.body(.@"1h", span)); try testing.expectEqual(@as(usize, 1), db.read_tx_faults.endCapture()); // Nothing was published under the new key: the next request rebuilds and // sees the second row, rather than being served the failed read's work or // the first row's body under a key that now describes two. const rebuilt = try fx.body(.@"1h", span); try testing.expect(!std.mem.eql(u8, stored, rebuilt)); try testing.expect(std.mem.containsAtLeast(u8, rebuilt, 1, "\"queries\":2")); }