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 &current.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}