//! Where the query path hands off a finished query (PLAN ยง11.4). //! //! Milestone 6 gave the handler a `Logger`; milestone 8 gives it a second //! consumer, the SSE hub. `QuerySink` is that fanout, and it exists so the //! handler still makes one call and the privacy transforms still run exactly //! once, before either consumer sees the entry. //! //! Order is load-bearing: PLAN:455 puts fanout ahead of persistence, so a live //! stream shows a query while the row is still queued for the database. //! `Hub.publish` copies and returns, so publishing first costs the query path //! nothing it would not have paid anyway. const std = @import("std"); const logger = @import("../storage/logger.zig"); const logger_controller = @import("../storage/logger_controller.zig"); const sse = @import("../web/sse.zig"); pub const QuerySink = struct { /// The controller, not a `Logger`: `logging.query_log_buffer_max` can /// change while the server runs, and the generation a producer enqueues /// into has to be the one that is live at that moment. controller: *logger_controller.Controller, /// Null when `web.enabled` is false: nothing subscribes, so nothing needs /// a hub, and the DNS path pays one null check. hub: ?*sse.Hub, pub fn init(controller: *logger_controller.Controller, hub: ?*sse.Hub) QuerySink { return .{ .controller = controller, .hub = hub }; } /// Transforms once, publishes, then enqueues. Never blocks the query path /// and never fails: both consumers drop rather than wait. /// /// The borrow spans both halves. A resize that lands between them would /// otherwise leave this entry going into a queue retirement has already /// closed, and that is exactly the drop window the controller exists to /// make impossible. pub fn log(self: *QuerySink, io: std.Io, entry: logger.Entry) void { const generation = self.controller.acquire(io); defer self.controller.release(io, generation); const transformed = generation.logger.transformed(entry); if (self.hub) |hub| hub.publish(io, transformed); generation.logger.logTransformed(io, transformed); } }; // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; fn sampleEntry(timestamp: i64, domain: []const u8) logger.Entry { return .init(.{ .timestamp = timestamp, .domain = domain, .client_ip = "192.0.2.10", .qtype = 1, }); } test "the sink publishes and logs the same entry" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const hub = try testing.allocator.create(sse.Hub); defer testing.allocator.destroy(hub); hub.init(); var queue_buf: [4]logger.Entry = undefined; var query_logger: logger.Logger = .init(.{}, &queue_buf); var owner: logger_controller.Borrowed = .{}; var sink: QuerySink = .init(owner.over(&query_logger), hub); const id = hub.subscribe(io).?; defer hub.unsubscribe(io, id); sink.log(io, sampleEntry(11, "example.com")); const streamed = hub.next(io, id).?; try testing.expectEqualStrings("example.com", streamed.domain()); try testing.expectEqual(@as(i64, 11), streamed.timestamp); const queued = try query_logger.queue.getOne(io); try testing.expectEqualStrings("example.com", queued.domain()); try testing.expectEqual(@as(i64, 11), queued.timestamp); } test "fanout does not depend on the entry reaching the queue" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const hub = try testing.allocator.create(sse.Hub); defer testing.allocator.destroy(hub); hub.init(); var queue_buf: [4]logger.Entry = undefined; var query_logger: logger.Logger = .init(.{}, &queue_buf); var owner: logger_controller.Borrowed = .{}; var sink: QuerySink = .init(owner.over(&query_logger), hub); const id = hub.subscribe(io).?; defer hub.unsubscribe(io, id); // A closed queue drops what it is handed. The stream still carries the // query, which is only true because the publish happens first. query_logger.shutdown(io); sink.log(io, sampleEntry(3, "ordered.example")); try testing.expectEqualStrings("ordered.example", hub.next(io, id).?.domain()); try testing.expectEqual(@as(u64, 1), query_logger.queries_dropped.load(.monotonic)); } test "the privacy transforms run once, before both consumers" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const hub = try testing.allocator.create(sse.Hub); defer testing.allocator.destroy(hub); hub.init(); var queue_buf: [4]logger.Entry = undefined; var query_logger: logger.Logger = .init( .{ .hide_domains = true, .hide_client_ips = true }, &queue_buf, ); var owner: logger_controller.Borrowed = .{}; var sink: QuerySink = .init(owner.over(&query_logger), hub); const id = hub.subscribe(io).?; defer hub.unsubscribe(io, id); sink.log(io, sampleEntry(4, "tracker.example")); const streamed = hub.next(io, id).?; try testing.expectEqualStrings(logger.hidden_marker, streamed.domain()); try testing.expectEqualStrings(logger.hidden_marker, streamed.clientIp()); const queued = try query_logger.queue.getOne(io); try testing.expectEqualStrings(logger.hidden_marker, queued.domain()); try testing.expectEqualStrings(logger.hidden_marker, queued.clientIp()); } test "a sink without a hub still logs" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var queue_buf: [4]logger.Entry = undefined; var query_logger: logger.Logger = .init(.{}, &queue_buf); var owner: logger_controller.Borrowed = .{}; var sink: QuerySink = .init(owner.over(&query_logger), null); sink.log(io, sampleEntry(5, "nohub.example")); const queued = try query_logger.queue.getOne(io); try testing.expectEqualStrings("nohub.example", queued.domain()); try testing.expectEqual(@as(u64, 0), query_logger.queries_dropped.load(.monotonic)); }