dwara_core/events/stream.rs
1//! Real-time access-record stream (DW-121, feature analysis section 5
2//! "Platform"): the record plumbing that mirrors the notice plumbing of
3//! DW-044. Where the event bus carries a handful of discrete
4//! operational events per minute (breaker transitions, ejections,
5//! config publishes), this pipeline carries ONE record per completed
6//! request — the raw firehose an operator points at their own
7//! warehouse or SIEM when the embedded analytics store (DW-043) is not
8//! the system of record. It complements, never replaces: the two are
9//! configured independently (a deployment can run either, both, or
10//! neither) and share nothing at runtime — different channels, different
11//! tasks, different failure domains.
12//!
13//! # Placement
14//!
15//! The stream lives in the events domain beside the webhook deliverer
16//! because it IS an outbound delivery pipeline: the sink reuses the
17//! DW-044 delivery engine (`webhook::deliver_with_retry` — one total
18//! timeout per delivery, exponential backoff, the transient status
19//! set) and consumes the access record type from `observability`, both
20//! of which the events domain may import. The analytics domain cannot
21//! (its dependency set is config/observability/extensions), and the
22//! firehose needs nothing from the SQLite store — moving it there
23//! would force a dependency-graph change to share an engine that
24//! already lives one module over.
25//!
26//! # Wire format (stable shape)
27//!
28//! One NDJSON body per flushed batch — one JSON object per line, one
29//! line per record, `application/x-ndjson`:
30//!
31//! ```json
32//! {"id":"rec-18f3c2a1b9d0-00000a","gateway":"dwara-8213-18f3c2910b07",
33//! "timestamp":"2026-08-30T09:00:00.123Z","request_id":"req-...",
34//! "listener":"edge","route":"billing","consumer":"acme",
35//! "upstream":"billing-v1","endpoint":"10.0.0.4:8443","method":"GET",
36//! "path":"/v1/invoices","status":200,"duration_ms":4.2,"attempts":1,
37//! "rate_limited":false,"broken":false,"shed":false,
38//! "dimensions":{"plan":"gold"}}
39//! ```
40//!
41//! The field list is the access record's redacted-by-construction set:
42//! `path` never carries a query string, there are no headers, no
43//! credentials, and `dimensions` carries only config-declared
44//! header-sourced tags (DW-043's grammar, capped at 16 x 128 bytes).
45//! `id` is process-unique and monotonic (`rec-<hex unix ms>-<hex seq>`)
46//! and `gateway` is the process instance label — together they give a
47//! receiver per-instance ordering even across restarts. A record whose
48//! serialized line exceeds [`MAX_RECORD_BYTES`] (only possible via
49//! absurd path/dimension lengths — the same bound class as a webhook
50//! envelope) is dropped and counted, never truncated: a receiver
51//! should never see a malformed line.
52//!
53//! # Batching: one delivery per flushed batch
54//!
55//! The flusher accumulates records and flushes on the first of:
56//! `batch_max` records queued, the batch byte cap [`MAX_BATCH_BYTES`]
57//! reached, or `flush_ms` elapsed since the batch's first record (all
58//! three read live from the current generation). ONE delivery — with
59//! its whole retry cycle — is made per batch, not per record: a
60//! warehouse ingest endpoint wants throughput-shaped traffic, and
61//! per-record delivery would multiply the sink's load by the batch
62//! factor for zero information gain. Deliveries are made STRICTLY IN
63//! ORDER, inline in the flusher task: a record firehose's arrival
64//! order is a real receiver expectation (unlike alert events, which
65//! the deliverer dispatches as independent tasks). The cost of that
66//! ordering is deliberate and bounded: one slow batch holds the queue
67//! back by at most its `timeout_ms` budget, after which it fails and
68//! the queue moves on — and a queue that outgrows its `buffer` bound
69//! drops at OFFER time (counted), never blocks the request path.
70//!
71//! # Emission contract: never backpressure the dataplane
72//!
73//! [`AccessRecordStream::offer`] is an enabled-flag check plus a
74//! bounded-channel `try_send`. When the queue is full (or the flusher
75//! is stalled behind a slow batch) the record is DROPPED and the drop
76//! counted ([`AccessRecordStream::dropped`], surfaced as the
77//! `dwara_access_records_dropped_total` gauge at scrape time). There
78//! is no blocking wait and no unbounded buffer: a dead sink degrades
79//! the stream's completeness counters, never the gateway's latency.
80//! When the current generation has no compiled sink the flag is off
81//! and `offer` returns before allocating anything — an unconfigured
82//! stream costs one relaxed atomic load per request.
83//!
84//! # The sink seam
85//!
86//! [`RecordSink`] is deliberately small: one method, one batch, one
87//! boolean answer. `webhook` is the one shipped implementation; a
88//! Kafka producer is the documented second slot, deferred by the
89//! lean-deps rule (the same decision that keeps Parquet out of DW-156
90//! backlog territory: a sink that drags a client library must earn its
91//! dependency weight). Sinks are compiled per config generation (a
92//! `kafka` variant would compile its client the same way) and pushed
93//! to the flusher over a watch channel — a reload retargets the stream
94//! with no restart, mid-batch boundaries aside.
95//!
96//! # Lifecycle
97//!
98//! The channel and its capacity are constructed once per process (the
99//! capacity is a boot-time property; dwara-bin always constructs the
100//! stream so a live reload can arm it) and the flusher is one
101//! background task stopped by the shutdown watch, which drains what is
102//! already queued into one final flush attempt before returning. The
103//! gateway is not a durable queue: a record offered but not yet
104//! delivered at process death is lost with only its counters as the
105//! witness.
106
107use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
108use std::sync::{Arc, Mutex};
109
110use bytes::Bytes;
111use tokio::sync::{mpsc, watch};
112
113use crate::config::{AnalyticsStreamConfig, AnalyticsStreamSink, AnalyticsStreamWebhook};
114use crate::observability::{AccessRecord, Observability};
115
116use super::webhook::{self, WebhookTarget};
117
118/// Hard cap on one serialized record line (bytes). The field list is
119/// bounded (path length and 16x128-byte dimensions are the only
120/// growth axes), so this only binds absurd shapes; an over-cap line is
121/// dropped and counted, never sent truncated.
122pub const MAX_RECORD_BYTES: usize = 16 * 1024;
123/// Hard cap on one flushed batch (bytes), the byte half of the flush
124/// trigger (with `batch_max` records, whichever comes first). Batches
125/// are delivered inline and in order, so this is also the flusher's
126/// per-delivery memory bound.
127pub const MAX_BATCH_BYTES: usize = 2 * 1024 * 1024;
128/// `User-Agent` stamped on every batch delivery.
129const USER_AGENT: &str = "dwara-record-stream";
130
131/// One completed request as it crosses the stream channel: the access
132/// record's owned copy plus the identity the flusher stamps into the
133/// envelope. Constructed on the request-completion path (the same
134/// allocation class as the analytics store's raw-record copy).
135#[derive(Debug)]
136pub struct StreamRecord {
137 /// Process-unique, monotonically assigned at offer time:
138 /// `rec-<hex unix ms>-<hex seq>` (the event-id shape).
139 pub id: String,
140 /// Instance label (`dwara-<pid>-<boot ms>`; every envelope of this
141 /// process carries the same one).
142 pub gateway: Arc<str>,
143 /// Completion time, Unix epoch milliseconds.
144 pub timestamp_ms: u64,
145 request_id: String,
146 listener: String,
147 route: String,
148 consumer: String,
149 upstream: String,
150 endpoint: String,
151 method: String,
152 path: String,
153 status: u16,
154 duration_ms: f64,
155 attempts: u32,
156 rate_limited: bool,
157 broken: bool,
158 shed: bool,
159 dimensions: Vec<(String, String)>,
160}
161
162impl StreamRecord {
163 /// The owned completion-time copy of one access record (identity
164 /// assigned here so the flusher stays serialization-only).
165 pub fn from_access(
166 rec: &AccessRecord,
167 id: String,
168 gateway: Arc<str>,
169 timestamp_ms: u64,
170 ) -> Self {
171 StreamRecord {
172 id,
173 gateway,
174 timestamp_ms,
175 request_id: rec.request_id.clone(),
176 listener: rec.listener.clone(),
177 route: rec.route.clone(),
178 consumer: rec.consumer.clone(),
179 upstream: rec.upstream.clone().unwrap_or_default(),
180 endpoint: rec.endpoint.clone().unwrap_or_default(),
181 method: rec.method.clone(),
182 path: rec.path.clone(),
183 status: rec.status,
184 duration_ms: rec.duration_ms,
185 attempts: rec.attempts,
186 rate_limited: rec.rate_limited,
187 broken: rec.broken,
188 shed: rec.shed,
189 dimensions: rec.custom.clone(),
190 }
191 }
192
193 /// The NDJSON line for this record: one JSON object, trailing
194 /// newline, no truncation (the flusher enforces the byte cap).
195 pub fn line(&self) -> String {
196 let mut dims = serde_json::Map::new();
197 for (k, v) in &self.dimensions {
198 dims.insert(k.clone(), serde_json::Value::String(v.clone()));
199 }
200 let envelope = RecordEnvelope {
201 id: &self.id,
202 gateway: &self.gateway,
203 timestamp: webhook::rfc3339_ms(self.timestamp_ms),
204 request_id: &self.request_id,
205 listener: &self.listener,
206 route: &self.route,
207 consumer: &self.consumer,
208 upstream: &self.upstream,
209 endpoint: &self.endpoint,
210 method: &self.method,
211 path: &self.path,
212 status: self.status,
213 duration_ms: self.duration_ms,
214 attempts: self.attempts,
215 rate_limited: self.rate_limited,
216 broken: self.broken,
217 shed: self.shed,
218 dimensions: dims,
219 };
220 let mut line = serde_json::to_string(&envelope)
221 .expect("envelope fields are strings, numbers, and booleans");
222 line.push('\n');
223 line
224 }
225}
226
227/// The stable wire envelope (see the module docs for the shape and the
228/// redaction contract).
229#[derive(serde::Serialize)]
230struct RecordEnvelope<'a> {
231 id: &'a str,
232 gateway: &'a str,
233 timestamp: String,
234 request_id: &'a str,
235 listener: &'a str,
236 route: &'a str,
237 consumer: &'a str,
238 upstream: &'a str,
239 endpoint: &'a str,
240 method: &'a str,
241 path: &'a str,
242 status: u16,
243 duration_ms: f64,
244 attempts: u32,
245 rate_limited: bool,
246 broken: bool,
247 shed: bool,
248 dimensions: serde_json::Map<String, serde_json::Value>,
249}
250
251/// One flushed batch's delivery destination (DW-121). Deliberately
252/// small — one method, one batch, one answer — because the pipeline's
253/// guarantees (ordering, bounded memory, drop-and-count) live in the
254/// FLUSHER, not in the sink: an implementation only decides where a
255/// batch goes and whether the receiver took it.
256///
257/// `webhook` is the one shipped implementation
258/// ([`WebhookRecordSink`]); a Kafka producer is the documented second
259/// slot (see the module docs and the config enum's), deferred by the
260/// lean-deps rule. Implementations must enforce their OWN delivery
261/// budget: the flusher calls `deliver_batch` inline and in order, so a
262/// sink without a timeout is a stuck queue.
263#[async_trait::async_trait]
264pub trait RecordSink: Send + Sync {
265 /// Deliver one flushed NDJSON batch of `records` lines. Returns
266 /// whether the batch was accepted (2xx on some attempt for the
267 /// webhook sink) — the flusher only logs; outcome counting is the
268 /// implementation's, in the metric family it owns.
269 async fn deliver_batch(&self, batch: Bytes, records: usize) -> bool;
270}
271
272/// The webhook batch sink (DW-121, `analytics_stream.sink.webhook`):
273/// one POST per flushed batch, body the NDJSON lines, media type
274/// `application/x-ndjson`, delivered through the shared DW-044 engine
275/// (one total timeout per batch, exponential backoff, the transient
276/// status set — see `events::webhook` for the contract).
277pub struct WebhookRecordSink {
278 target: WebhookTarget,
279 obs: Arc<Observability>,
280}
281
282impl WebhookRecordSink {
283 /// Compile the sink from its config block: URL decomposition and
284 /// secret-reference header resolution through the same
285 /// `WebhookTarget` bottom the alert deliverer uses, so the two
286 /// pipelines cannot drift on URL, header, or retry semantics.
287 /// Fails with a log-safe message (the caller skips the sink
288 /// loudly, fail closed — the same compile-time backstop as alert
289 /// webhook targets).
290 pub fn compile(
291 cfg: &AnalyticsStreamWebhook,
292 obs: Arc<Observability>,
293 ssrf_filter: crate::config::ssrf::SsrfFilter,
294 ) -> Result<Self, String> {
295 let target = WebhookTarget::compile_endpoint(
296 &cfg.url,
297 &cfg.headers,
298 cfg.timeout_ms,
299 cfg.max_attempts,
300 cfg.backoff_base_ms,
301 cfg.backoff_cap_ms,
302 ssrf_filter,
303 )?;
304 Ok(WebhookRecordSink { target, obs })
305 }
306
307 /// The configured URL (operator config, safe to log).
308 pub fn url(&self) -> &str {
309 self.target.url()
310 }
311}
312
313impl std::fmt::Debug for WebhookRecordSink {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 f.debug_struct("WebhookRecordSink")
316 .field("url", &self.url())
317 .finish_non_exhaustive()
318 }
319}
320
321#[async_trait::async_trait]
322impl RecordSink for WebhookRecordSink {
323 async fn deliver_batch(&self, batch: Bytes, records: usize) -> bool {
324 match webhook::deliver_with_retry(&self.target, batch, "application/x-ndjson", USER_AGENT)
325 .await
326 {
327 webhook::DeliveryEnd::Delivered { attempts } => {
328 tracing::debug!(
329 code = "record_stream_batch_delivered",
330 url = %self.url(),
331 records,
332 attempt = attempts,
333 "record batch delivered"
334 );
335 self.obs.record_access_stream("delivered", records as u64);
336 true
337 }
338 webhook::DeliveryEnd::Failed { attempts, error } => {
339 tracing::warn!(
340 code = "record_stream_batch_failed",
341 url = %self.url(),
342 records,
343 attempt = attempts,
344 "record batch delivery failed: {error}"
345 );
346 self.obs.record_access_stream("failed", records as u64);
347 false
348 }
349 }
350 }
351}
352
353/// The Kafka batch sink (SCALE-08, #187,
354/// `analytics_stream.sink.kafka`): produces each flushed batch as
355/// NDJSON messages to a Kafka topic via a Kafka REST Proxy. The REST
356/// Proxy endpoint receives the batch as an HTTP POST with the
357/// `Content-Type: application/vnd.kafka.binary.v2+json` media type;
358/// each NDJSON line becomes a Kafka message value (base64 encoded
359/// per the REST Proxy spec). Reuses the DW-044 webhook delivery
360/// engine's retry/budget shape — one delivery (with its retries) per
361/// batch.
362pub struct KafkaRecordSink {
363 target: WebhookTarget,
364 obs: Arc<Observability>,
365}
366
367impl KafkaRecordSink {
368 /// Compile the sink from its config block: URL decomposition and
369 /// secret-reference header resolution through the same
370 /// `WebhookTarget` bottom the webhook sink uses, so the two
371 /// sinks cannot drift on URL, header, or retry semantics. Fails
372 /// with a log-safe message (the caller skips the sink loudly,
373 /// fail closed).
374 pub fn compile(
375 cfg: &crate::config::AnalyticsStreamKafka,
376 obs: Arc<Observability>,
377 ssrf_filter: crate::config::ssrf::SsrfFilter,
378 ) -> Result<Self, String> {
379 let target = WebhookTarget::compile_endpoint(
380 &cfg.rest_proxy_url,
381 &cfg.headers,
382 cfg.timeout_ms,
383 cfg.max_attempts,
384 cfg.backoff_base_ms,
385 cfg.backoff_cap_ms,
386 ssrf_filter,
387 )?;
388 Ok(KafkaRecordSink { target, obs })
389 }
390
391 /// The configured REST Proxy URL (operator config, safe to log).
392 pub fn url(&self) -> &str {
393 self.target.url()
394 }
395}
396
397impl std::fmt::Debug for KafkaRecordSink {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 f.debug_struct("KafkaRecordSink")
400 .field("url", &self.url())
401 .finish_non_exhaustive()
402 }
403}
404
405#[async_trait::async_trait]
406impl RecordSink for KafkaRecordSink {
407 async fn deliver_batch(&self, batch: Bytes, records: usize) -> bool {
408 // Encode the NDJSON batch as a Kafka REST Proxy v2+json
409 // request body: each NDJSON line becomes a base64-encoded
410 // message value in the "records" array.
411 let body = kafka_rest_proxy_body(&batch);
412 match webhook::deliver_with_retry(
413 &self.target,
414 body,
415 "application/vnd.kafka.binary.v2+json",
416 USER_AGENT,
417 )
418 .await
419 {
420 webhook::DeliveryEnd::Delivered { attempts } => {
421 tracing::debug!(
422 code = "record_stream_kafka_batch_delivered",
423 url = %self.url(),
424 records,
425 attempt = attempts,
426 "kafka record batch delivered"
427 );
428 self.obs.record_access_stream("delivered", records as u64);
429 true
430 }
431 webhook::DeliveryEnd::Failed { attempts, error } => {
432 tracing::warn!(
433 code = "record_stream_kafka_batch_failed",
434 url = %self.url(),
435 records,
436 attempt = attempts,
437 "kafka record batch delivery failed: {error}"
438 );
439 self.obs.record_access_stream("failed", records as u64);
440 false
441 }
442 }
443 }
444}
445
446/// Encode an NDJSON batch as a Kafka REST Proxy v2+json request body.
447/// Each NDJSON line becomes a base64-encoded message value in the
448/// "records" array, per the Confluent REST Proxy spec:
449/// `{"records":[{"value":"<base64>"}]}`.
450fn kafka_rest_proxy_body(batch: &Bytes) -> Bytes {
451 use base64::Engine;
452 let lines: Vec<&[u8]> = batch
453 .split(|&b| b == b'\n')
454 .filter(|line| !line.is_empty())
455 .collect();
456 let mut json = String::with_capacity(batch.len() * 2);
457 json.push_str("{\"records\":[");
458 for (i, line) in lines.iter().enumerate() {
459 if i > 0 {
460 json.push(',');
461 }
462 json.push_str("{\"value\":\"");
463 json.push_str(&base64::engine::general_purpose::STANDARD.encode(line));
464 json.push_str("\"}");
465 }
466 json.push_str("]}");
467 Bytes::from(json)
468}
469
470/// The per-generation compiled stream state (DW-121): pushed to the
471/// flusher over a watch channel by the dataplane's refresh. An empty
472/// `sinks` list is the disabled state — the offer path checks a flag
473/// the refresh derives from exactly this, so an unconfigured stream
474/// never queues a record.
475#[derive(Clone)]
476pub struct StreamTargets {
477 /// The compiled sinks, in delivery order.
478 pub sinks: Vec<Arc<dyn RecordSink>>,
479 /// Maximum batch latency (ms), read live per flush cycle.
480 pub flush_ms: u64,
481 /// Maximum records per batch, read live per flush cycle.
482 pub batch_max: usize,
483}
484
485impl StreamTargets {
486 /// The disabled state (no block configured).
487 pub fn empty() -> Self {
488 StreamTargets {
489 sinks: Vec::new(),
490 flush_ms: crate::config::DEFAULT_STREAM_FLUSH_MS,
491 batch_max: crate::config::DEFAULT_STREAM_BATCH_MAX as usize,
492 }
493 }
494}
495
496/// Compile one generation's stream state from the config block. A sink
497/// whose compilation fails is skipped with a LOUD error (fail closed —
498/// never delivered with placeholder bytes, never fatal to the
499/// generation); if that leaves no sinks, the stream is disabled and
500/// records drop at offer time with the counters as the witness.
501pub fn compile_stream_targets(
502 cfg: Option<&AnalyticsStreamConfig>,
503 obs: &Arc<Observability>,
504 ssrf_filter: crate::config::ssrf::SsrfFilter,
505) -> StreamTargets {
506 let Some(cfg) = cfg else {
507 return StreamTargets::empty();
508 };
509 let mut sinks = Vec::new();
510 match &cfg.sink {
511 AnalyticsStreamSink::Webhook(wh) => {
512 match WebhookRecordSink::compile(wh, Arc::clone(obs), ssrf_filter) {
513 Ok(sink) => sinks.push(Arc::new(sink) as Arc<dyn RecordSink>),
514 Err(error) => tracing::error!(
515 code = "record_stream_sink_unusable",
516 "analytics_stream sink skipped for this generation (fail closed): {error}"
517 ),
518 }
519 }
520 AnalyticsStreamSink::Kafka(k) => {
521 match KafkaRecordSink::compile(k, Arc::clone(obs), ssrf_filter) {
522 Ok(sink) => sinks.push(Arc::new(sink) as Arc<dyn RecordSink>),
523 Err(error) => tracing::error!(
524 code = "record_stream_kafka_sink_unusable",
525 "analytics_stream kafka sink skipped for this generation (fail closed): {error}"
526 ),
527 }
528 }
529 }
530 StreamTargets {
531 flush_ms: cfg
532 .flush_ms
533 .unwrap_or(crate::config::DEFAULT_STREAM_FLUSH_MS),
534 batch_max: cfg
535 .batch_max
536 .unwrap_or(crate::config::DEFAULT_STREAM_BATCH_MAX) as usize,
537 sinks,
538 }
539}
540
541/// The bounded record queue plus its counters and enabled flag. Share
542/// via `Arc`; the dataplane holds it behind an `ArcSwapOption` set
543/// once at boot.
544pub struct AccessRecordStream {
545 tx: mpsc::Sender<StreamRecord>,
546 rx: Mutex<Option<mpsc::Receiver<StreamRecord>>>,
547 /// Whether the current generation compiled at least one sink.
548 /// Written only by the config refresh; read on every offer.
549 enabled: AtomicBool,
550 instance: Arc<str>,
551 next_seq: AtomicU64,
552 offered: AtomicU64,
553 dropped: AtomicU64,
554}
555
556impl AccessRecordStream {
557 /// New stream with an explicit channel capacity (the boot-time
558 /// property; see the module docs).
559 pub fn with_capacity(capacity: usize) -> Arc<Self> {
560 let (tx, rx) = mpsc::channel(capacity.max(1));
561 Arc::new(AccessRecordStream {
562 tx,
563 rx: Mutex::new(Some(rx)),
564 enabled: AtomicBool::new(false),
565 instance: Arc::from(super::generate_instance_id()),
566 next_seq: AtomicU64::new(0),
567 offered: AtomicU64::new(0),
568 dropped: AtomicU64::new(0),
569 })
570 }
571
572 /// Take the single-consumer receiver (the flusher's end; the first
573 /// caller wins).
574 pub fn take_receiver(&self) -> Option<mpsc::Receiver<StreamRecord>> {
575 self.rx
576 .lock()
577 .expect("record stream receiver lock poisoned")
578 .take()
579 }
580
581 /// The instance label stamped on every envelope.
582 pub fn instance(&self) -> &str {
583 &self.instance
584 }
585
586 /// Arm or disarm the offer path (the dataplane's refresh calls
587 /// this with `!targets.sinks.is_empty()` every generation).
588 pub fn set_enabled(&self, on: bool) {
589 self.enabled.store(on, Ordering::Release);
590 }
591
592 /// Whether offers currently queue (read by tests and diagnostics).
593 pub fn enabled(&self) -> bool {
594 self.enabled.load(Ordering::Acquire)
595 }
596
597 /// Records offered (and queued) since process start.
598 pub fn offered(&self) -> u64 {
599 self.offered.load(Ordering::Relaxed)
600 }
601
602 /// Records dropped at offer time (queue full, or the flusher
603 /// stalled behind a slow batch). The never-block posture's honest
604 /// loss counter, surfaced as `dwara_access_records_dropped_total`.
605 pub fn dropped(&self) -> u64 {
606 self.dropped.load(Ordering::Relaxed)
607 }
608
609 /// The request-completion hot path (DW-121): one enabled-flag
610 /// check, one `try_send`. NEVER blocks; a full queue drops and
611 /// counts (throttled log, the analytics writer's cadence). A
612 /// disabled stream returns before allocating: unconfigured means
613 /// one relaxed atomic load per request.
614 pub fn offer(&self, rec: &AccessRecord) {
615 if !self.enabled.load(Ordering::Relaxed) {
616 return;
617 }
618 let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
619 let now = super::now_unix_ms();
620 let record = StreamRecord::from_access(
621 rec,
622 format!("rec-{:x}-{:06x}", now, seq & 0xff_ffff),
623 Arc::clone(&self.instance),
624 now,
625 );
626 match self.tx.try_send(record) {
627 Ok(()) => {
628 self.offered.fetch_add(1, Ordering::Relaxed);
629 }
630 Err(_) => {
631 let n = self.dropped.fetch_add(1, Ordering::Relaxed);
632 if n.is_multiple_of(4096) {
633 tracing::warn!(
634 code = "record_stream_channel_full",
635 total_dropped = n + 1,
636 "record stream channel full; dropping records \
637 (never blocking the dataplane)"
638 );
639 }
640 }
641 }
642 }
643}
644
645/// Refresh the stream's observation gauges at scrape time (the event
646/// bus's model: the offer path bumps plain atomics, and only the
647/// scrape couples them to the registry).
648pub fn refresh_stream_gauges(stream: &AccessRecordStream, obs: &Observability) {
649 obs.set_access_records_offered(stream.offered() as i64);
650 obs.set_access_records_dropped(stream.dropped() as i64);
651}
652
653/// In-order batch assembly: appends serialized lines and answers
654/// whether a flush trigger has been reached. One instance is the
655/// flusher's whole in-flight state (bounded by `MAX_BATCH_BYTES`).
656struct Batcher {
657 lines: Vec<u8>,
658 records: usize,
659}
660
661impl Batcher {
662 fn new() -> Self {
663 Batcher {
664 lines: Vec::with_capacity(8 * 1024),
665 records: 0,
666 }
667 }
668
669 fn is_empty(&self) -> bool {
670 self.records == 0
671 }
672
673 /// Append one serialized line.
674 fn push(&mut self, line: &str) {
675 self.lines.extend_from_slice(line.as_bytes());
676 self.records += 1;
677 }
678
679 /// Whether a flush trigger has been reached (record count or byte
680 /// cap).
681 fn should_flush(&self, batch_max: usize) -> bool {
682 self.records >= batch_max || self.lines.len() >= MAX_BATCH_BYTES
683 }
684
685 /// Take the assembled batch (body, record count); `None` when
686 /// empty.
687 fn take(&mut self) -> Option<(Bytes, usize)> {
688 if self.records == 0 {
689 return None;
690 }
691 let records = self.records;
692 self.records = 0;
693 Some((Bytes::from(std::mem::take(&mut self.lines)), records))
694 }
695}
696
697/// The flusher loop (DW-121): drain the stream channel, assemble
698/// ordered batches, and deliver each batch — inline, in order, one
699/// retry cycle per batch — to the current generation's sinks. Stops on
700/// shutdown after draining what is already queued into one final flush
701/// attempt (the gateway is not a durable queue; an offer undelivered
702/// at process death is lost with its counters as the witness).
703///
704/// Spawned by the dataplane
705/// (`DataPlane::spawn_record_stream_flusher`) so the binary and tests
706/// share one wiring path.
707pub async fn run_stream_flusher(
708 mut rx: mpsc::Receiver<StreamRecord>,
709 targets: watch::Receiver<StreamTargets>,
710 obs: Arc<Observability>,
711 mut shutdown: watch::Receiver<()>,
712) {
713 let mut batch = Batcher::new();
714 let mut tick = flush_tick(targets.borrow().flush_ms);
715 loop {
716 // Apply the current generation's cadence: a live reload that
717 // changes flush_ms reschedules the ticker from now (missed
718 // ticks delay, never burst).
719 let flush_ms = targets.borrow().flush_ms;
720 let batch_max = targets.borrow().batch_max.max(1);
721 let record = tokio::select! {
722 _ = shutdown.changed() => {
723 // Drain what is already queued, flush it once, stop.
724 while let Ok(r) = rx.try_recv() {
725 push_record(&mut batch, &r, &obs);
726 }
727 flush(&mut batch, &targets, &obs).await;
728 return;
729 }
730 r = rx.recv() => match r {
731 Some(r) => r,
732 None => {
733 flush(&mut batch, &targets, &obs).await;
734 return;
735 }
736 },
737 _ = tick.tick() => {
738 if !batch.is_empty() {
739 flush(&mut batch, &targets, &obs).await;
740 }
741 reschedule(&mut tick, targets.borrow().flush_ms, flush_ms);
742 continue;
743 }
744 };
745 push_record(&mut batch, &record, &obs);
746 if batch.should_flush(batch_max) {
747 flush(&mut batch, &targets, &obs).await;
748 }
749 reschedule(&mut tick, targets.borrow().flush_ms, flush_ms);
750 }
751}
752
753/// Serialize one record into the batch, enforcing the per-record byte
754/// cap (drop-and-count, never truncate).
755fn push_record(batch: &mut Batcher, record: &StreamRecord, obs: &Observability) {
756 let line = record.line();
757 if line.len() > MAX_RECORD_BYTES {
758 tracing::warn!(
759 code = "record_stream_record_dropped",
760 size = line.len(),
761 "record line exceeds the {} byte cap; dropped",
762 MAX_RECORD_BYTES
763 );
764 obs.record_access_stream("dropped", 1);
765 return;
766 }
767 batch.push(&line);
768}
769
770/// One flush: deliver the assembled batch to every sink, in order,
771/// inline. An empty batch is a no-op.
772async fn flush(batch: &mut Batcher, targets: &watch::Receiver<StreamTargets>, obs: &Observability) {
773 let Some((body, records)) = batch.take() else {
774 return;
775 };
776 let current = targets.borrow().clone();
777 if current.sinks.is_empty() {
778 // The generation disabled the stream with records still
779 // queued: the tail is counted as dropped so the accounting
780 // identity (offered == delivered + failed + dropped) holds —
781 // an honest loss counter for a deliberate stop.
782 tracing::debug!(
783 code = "record_stream_tail_dropped",
784 records,
785 "record stream disabled with a queued tail; counted as dropped"
786 );
787 obs.record_access_stream("dropped", records as u64);
788 return;
789 }
790 for sink in ¤t.sinks {
791 // Sequential on purpose: order is the contract (see the module
792 // docs). A failed batch does not stop the sinks after it.
793 sink.deliver_batch(body.clone(), records).await;
794 }
795}
796
797/// A flush-interval ticker (missed ticks delay — a backlog must not
798/// burst).
799fn flush_tick(flush_ms: u64) -> tokio::time::Interval {
800 let mut tick = tokio::time::interval(std::time::Duration::from_millis(flush_ms.max(1)));
801 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
802 tick
803}
804
805/// Rebuild the ticker when the live cadence changed.
806fn reschedule(tick: &mut tokio::time::Interval, current_ms: u64, was_ms: u64) {
807 if current_ms != was_ms {
808 *tick = flush_tick(current_ms);
809 }
810}