Skip to main content

ant_core/node/daemon/forward/
sink.rs

1//! Where documents go, and how hard the forwarder tries to get them there.
2//!
3//! This is logs-only telemetry, so the governing rule is that nothing here may grow without bound
4//! or block waiting for an endpoint that is not answering. A user's node keeps running whatever the
5//! ingest endpoint is doing; at worst they lose some log lines, which is a cost they can afford and
6//! a stalled or memory-hungry daemon is not.
7//!
8//! Delivery is per-document rather than per-request. A bulk endpoint can accept most of a batch and
9//! reject part of it, so a batch is retried by *position* — only the documents that asked to be
10//! retried, never the whole thing. Whole-request replay is reserved for a transport failure, where
11//! nothing is known about what landed, and is safe there only because every document carries a
12//! deterministic `_id`.
13
14use std::collections::VecDeque;
15use std::time::Duration;
16
17use futures::future::BoxFuture;
18
19use super::document::ForwardDocument;
20
21/// Maximum documents held in memory awaiting delivery.
22///
23/// At roughly a kilobyte per event this is a few megabytes — enough to ride out a short endpoint
24/// outage, small enough that a long one costs the user nothing they would notice.
25pub const DEFAULT_QUEUE_CAPACITY: usize = 10_000;
26
27/// Maximum documents in one bulk request.
28pub const DEFAULT_BATCH_DOCUMENTS: usize = 500;
29
30/// Soft cap on a batch's serialized size. The endpoint's proxy rejects bodies over 50 MB and
31/// Elasticsearch itself over 100 MB; a few megabytes stays far away from both.
32pub const DEFAULT_BATCH_BYTES: usize = 4 * 1024 * 1024;
33
34/// What happened to one submitted document.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum DocumentOutcome {
37    /// Indexed, or already present from an earlier attempt — either way, done with.
38    Delivered,
39    /// Worth another attempt: the endpoint was busy or briefly unavailable.
40    Retryable,
41    /// Rejected in a way that retrying cannot fix, e.g. a permissions or mapping error.
42    Rejected,
43}
44
45/// Result of submitting one batch.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct BatchOutcome {
48    /// Per-position outcomes, aligned with the submitted slice. Empty when the request itself
49    /// failed and nothing can be said about individual documents.
50    pub outcomes: Vec<DocumentOutcome>,
51    /// Set when the request did not complete at all — connection refused, timed out, TLS failure.
52    /// The whole batch may then be replayed, which the deterministic `_id`s make safe.
53    pub transport_failure: bool,
54    /// Human-readable description of the most relevant failure, for `status` output.
55    pub error: Option<String>,
56}
57
58impl BatchOutcome {
59    /// Every document accepted.
60    #[must_use]
61    pub fn all_delivered(count: usize) -> Self {
62        Self {
63            outcomes: vec![DocumentOutcome::Delivered; count],
64            transport_failure: false,
65            error: None,
66        }
67    }
68
69    /// The request never completed.
70    #[must_use]
71    pub fn transport_failure(error: impl Into<String>) -> Self {
72        Self {
73            outcomes: Vec::new(),
74            transport_failure: true,
75            error: Some(error.into()),
76        }
77    }
78}
79
80/// Somewhere documents can be sent.
81///
82/// Boxed futures rather than `async fn` so the forwarder can hold a `dyn LogSink` and swap a mock
83/// in under test without being generic over the sink everywhere.
84pub trait LogSink: Send + Sync + 'static {
85    fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome>;
86
87    /// Short description of the destination, for status output and logs.
88    fn describe(&self) -> String;
89}
90
91/// A bounded in-memory queue of documents awaiting delivery.
92///
93/// When it fills, the **oldest** documents are dropped. Dropping the newest would be easier but
94/// wrong: during an outage the recent events are the ones describing what is going wrong, and they
95/// are what a beta debugger needs.
96#[derive(Debug)]
97pub struct DocumentQueue {
98    documents: VecDeque<ForwardDocument>,
99    capacity: usize,
100    dropped: u64,
101}
102
103impl DocumentQueue {
104    #[must_use]
105    pub fn new(capacity: usize) -> Self {
106        Self {
107            documents: VecDeque::new(),
108            capacity: capacity.max(1),
109            dropped: 0,
110        }
111    }
112
113    /// Add a document, evicting the oldest if the queue is full.
114    pub fn push(&mut self, document: ForwardDocument) {
115        if self.documents.len() >= self.capacity {
116            self.documents.pop_front();
117            self.dropped += 1;
118        }
119        self.documents.push_back(document);
120    }
121
122    /// Take the next batch, bounded by both document count and approximate size.
123    pub fn take_batch(&mut self, max_documents: usize, max_bytes: usize) -> Vec<ForwardDocument> {
124        let mut batch = Vec::new();
125        let mut bytes = 0;
126
127        while batch.len() < max_documents {
128            let Some(next) = self.documents.front() else {
129                break;
130            };
131            let next_bytes = next.approx_bytes();
132            // Always take at least one, so a single oversized document cannot wedge the queue.
133            if !batch.is_empty() && bytes + next_bytes > max_bytes {
134                break;
135            }
136            bytes += next_bytes;
137            batch.push(self.documents.pop_front().expect("front was just observed"));
138        }
139
140        batch
141    }
142
143    #[must_use]
144    pub fn len(&self) -> usize {
145        self.documents.len()
146    }
147
148    #[must_use]
149    pub fn is_empty(&self) -> bool {
150        self.documents.is_empty()
151    }
152
153    /// Documents discarded because the queue was full.
154    #[must_use]
155    pub fn dropped(&self) -> u64 {
156        self.dropped
157    }
158}
159
160/// How persistently a batch is retried before it is abandoned.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct RetryPolicy {
163    pub max_attempts: u32,
164    pub initial_backoff: Duration,
165    pub max_backoff: Duration,
166}
167
168impl Default for RetryPolicy {
169    fn default() -> Self {
170        Self {
171            max_attempts: 3,
172            initial_backoff: Duration::from_secs(2),
173            max_backoff: Duration::from_secs(30),
174        }
175    }
176}
177
178impl RetryPolicy {
179    /// Backoff before the given attempt number, doubling and then holding at the cap.
180    #[must_use]
181    pub fn backoff_for(&self, attempt: u32) -> Duration {
182        let exponent = attempt.saturating_sub(1).min(16);
183        let scaled = self
184            .initial_backoff
185            .saturating_mul(2u32.saturating_pow(exponent));
186        scaled.min(self.max_backoff)
187    }
188}
189
190/// What became of one delivery attempt, including its retries.
191#[derive(Debug, Clone, Default, PartialEq, Eq)]
192pub struct DeliveryReport {
193    pub delivered: u64,
194    /// Documents the endpoint refused in a way retrying cannot fix.
195    pub rejected: u64,
196    /// Documents abandoned with retries exhausted.
197    pub abandoned: u64,
198    pub attempts: u32,
199    pub error: Option<String>,
200}
201
202impl DeliveryReport {
203    /// Whether every document in the batch was accounted for without loss.
204    #[must_use]
205    pub fn is_complete_success(&self) -> bool {
206        self.rejected == 0 && self.abandoned == 0
207    }
208}
209
210/// Deliver a batch, retrying only the positions that asked for it.
211///
212/// `sleep` is injected so tests exercise the retry ladder without waiting real seconds.
213pub async fn deliver<S, F>(
214    sink: &S,
215    mut batch: Vec<ForwardDocument>,
216    policy: RetryPolicy,
217    mut sleep: F,
218) -> DeliveryReport
219where
220    S: LogSink + ?Sized,
221    F: FnMut(Duration) -> BoxFuture<'static, ()>,
222{
223    let mut report = DeliveryReport::default();
224
225    for attempt in 1..=policy.max_attempts {
226        report.attempts = attempt;
227        let outcome = sink.send(&batch).await;
228
229        if outcome.transport_failure {
230            report.error = outcome.error;
231            if attempt < policy.max_attempts {
232                sleep(policy.backoff_for(attempt)).await;
233                continue;
234            }
235            // Nothing is known about what landed. The batch is abandoned rather than replayed
236            // forever; a later attempt at the same documents would be idempotent, but holding them
237            // indefinitely is what unbounded memory looks like.
238            report.abandoned += batch.len() as u64;
239            return report;
240        }
241
242        if outcome.error.is_some() {
243            report.error = outcome.error.clone();
244        }
245
246        let mut retry = Vec::new();
247        for (index, document) in batch.into_iter().enumerate() {
248            match outcome.outcomes.get(index) {
249                Some(DocumentOutcome::Delivered) => report.delivered += 1,
250                Some(DocumentOutcome::Rejected) => report.rejected += 1,
251                Some(DocumentOutcome::Retryable) => retry.push(document),
252                // A response shorter than the batch: treat the unexplained tail as retryable rather
253                // than assume it landed.
254                None => retry.push(document),
255            }
256        }
257
258        if retry.is_empty() {
259            return report;
260        }
261
262        batch = retry;
263        if attempt < policy.max_attempts {
264            sleep(policy.backoff_for(attempt)).await;
265        }
266    }
267
268    report.abandoned += batch.len() as u64;
269    report
270}
271
272/// A sink that records what it was given, for tests.
273#[cfg(test)]
274pub mod mock {
275    use super::*;
276    use std::sync::{Arc, Mutex};
277
278    /// Records every batch and replies from a script of prepared outcomes.
279    pub struct MockSink {
280        pub batches: Mutex<Vec<Vec<ForwardDocument>>>,
281        responses: Mutex<VecDeque<BatchOutcome>>,
282        /// When set, `send` records the batch and then blocks until the notifier fires, standing in
283        /// for a request that is in flight when forwarding is revoked.
284        block_until: Option<Arc<tokio::sync::Notify>>,
285        /// Batches whose `send` actually returned, as opposed to being dropped mid-flight.
286        pub completed: Mutex<usize>,
287    }
288
289    impl MockSink {
290        /// A sink that accepts everything.
291        #[must_use]
292        pub fn accepting() -> Self {
293            Self {
294                batches: Mutex::new(Vec::new()),
295                responses: Mutex::new(VecDeque::new()),
296                block_until: None,
297                completed: Mutex::new(0),
298            }
299        }
300
301        /// A sink that replies with each prepared outcome in turn, accepting everything after.
302        #[must_use]
303        pub fn scripted(responses: Vec<BatchOutcome>) -> Self {
304            Self {
305                batches: Mutex::new(Vec::new()),
306                responses: Mutex::new(responses.into()),
307                block_until: None,
308                completed: Mutex::new(0),
309            }
310        }
311
312        /// A sink whose sends hang until `release` is notified.
313        #[must_use]
314        pub fn blocking(release: Arc<tokio::sync::Notify>) -> Self {
315            Self {
316                batches: Mutex::new(Vec::new()),
317                responses: Mutex::new(VecDeque::new()),
318                block_until: Some(release),
319                completed: Mutex::new(0),
320            }
321        }
322
323        /// How many sends ran to completion rather than being dropped mid-flight.
324        #[must_use]
325        pub fn completed_count(&self) -> usize {
326            *self.completed.lock().unwrap()
327        }
328
329        /// Ids of every document submitted, in submission order, across all batches.
330        #[must_use]
331        pub fn submitted_ids(&self) -> Vec<String> {
332            self.batches
333                .lock()
334                .unwrap()
335                .iter()
336                .flat_map(|batch| batch.iter().map(|d| d.id.clone()))
337                .collect()
338        }
339
340        #[must_use]
341        pub fn batch_count(&self) -> usize {
342            self.batches.lock().unwrap().len()
343        }
344    }
345
346    impl LogSink for MockSink {
347        fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome> {
348            Box::pin(async move {
349                self.batches.lock().unwrap().push(batch.to_vec());
350
351                if let Some(release) = &self.block_until {
352                    // Dropping this future here is what a cancelled delivery looks like: the
353                    // completion counter below is never reached.
354                    release.notified().await;
355                }
356
357                *self.completed.lock().unwrap() += 1;
358                self.responses
359                    .lock()
360                    .unwrap()
361                    .pop_front()
362                    .unwrap_or_else(|| BatchOutcome::all_delivered(batch.len()))
363            })
364        }
365
366        fn describe(&self) -> String {
367            "mock".to_string()
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::mock::MockSink;
375    use super::*;
376    use crate::node::daemon::forward::document::DocumentSource;
377
378    fn document(id: &str) -> ForwardDocument {
379        ForwardDocument {
380            id: id.to_string(),
381            index: "beta-nodes-2026.08.19".to_string(),
382            source: DocumentSource {
383                timestamp: "2026-08-19T20:50:00.000000Z".to_string(),
384                level: "INFO".to_string(),
385                target: None,
386                message: "m".to_string(),
387                node_id: "7".to_string(),
388                service: "node7".to_string(),
389                binary_version: "0.17.2".to_string(),
390                channel: "beta".to_string(),
391                os: "linux".to_string(),
392                arch: "x86_64".to_string(),
393                peer_id: None,
394                version: None,
395                commit: None,
396            },
397        }
398    }
399
400    fn documents(count: usize) -> Vec<ForwardDocument> {
401        (0..count).map(|i| document(&format!("doc-{i}"))).collect()
402    }
403
404    /// Sleep that returns instantly, recording nothing — the retry ladder is exercised, not waited on.
405    fn no_sleep() -> impl FnMut(Duration) -> BoxFuture<'static, ()> {
406        |_| Box::pin(async {})
407    }
408
409    #[test]
410    fn a_full_queue_drops_the_oldest_not_the_newest() {
411        let mut queue = DocumentQueue::new(3);
412        for i in 0..5 {
413            queue.push(document(&format!("doc-{i}")));
414        }
415
416        assert_eq!(queue.len(), 3);
417        assert_eq!(queue.dropped(), 2);
418
419        let batch = queue.take_batch(10, usize::MAX);
420        let ids: Vec<&str> = batch.iter().map(|d| d.id.as_str()).collect();
421        assert_eq!(
422            ids,
423            vec!["doc-2", "doc-3", "doc-4"],
424            "the recent events are the ones worth keeping"
425        );
426    }
427
428    #[test]
429    fn a_batch_is_bounded_by_document_count() {
430        let mut queue = DocumentQueue::new(100);
431        for doc in documents(10) {
432            queue.push(doc);
433        }
434
435        assert_eq!(queue.take_batch(4, usize::MAX).len(), 4);
436        assert_eq!(queue.len(), 6);
437    }
438
439    #[test]
440    fn a_batch_is_bounded_by_approximate_size() {
441        let mut queue = DocumentQueue::new(100);
442        for doc in documents(10) {
443            queue.push(doc);
444        }
445
446        let one = document("sizing").approx_bytes();
447        let batch = queue.take_batch(100, one * 3);
448        assert_eq!(batch.len(), 3);
449    }
450
451    /// A single document larger than the whole size budget must still get out.
452    #[test]
453    fn an_oversized_document_cannot_wedge_the_queue() {
454        let mut queue = DocumentQueue::new(10);
455        queue.push(document("huge"));
456
457        let batch = queue.take_batch(100, 1);
458        assert_eq!(batch.len(), 1);
459        assert!(queue.is_empty());
460    }
461
462    #[tokio::test]
463    async fn a_clean_batch_is_delivered_in_one_attempt() {
464        let sink = MockSink::accepting();
465        let report = deliver(&sink, documents(3), RetryPolicy::default(), no_sleep()).await;
466
467        assert_eq!(report.delivered, 3);
468        assert_eq!(report.attempts, 1);
469        assert!(report.is_complete_success());
470        assert_eq!(sink.batch_count(), 1);
471    }
472
473    /// The behaviour the ingest contract specifically warned about: a 200 response can still carry
474    /// per-document failures, and only the failing positions may be resent.
475    #[tokio::test]
476    async fn only_the_retryable_positions_are_resent() {
477        let sink = MockSink::scripted(vec![BatchOutcome {
478            outcomes: vec![
479                DocumentOutcome::Delivered,
480                DocumentOutcome::Retryable,
481                DocumentOutcome::Delivered,
482                DocumentOutcome::Retryable,
483            ],
484            transport_failure: false,
485            error: None,
486        }]);
487
488        let report = deliver(&sink, documents(4), RetryPolicy::default(), no_sleep()).await;
489
490        assert_eq!(report.delivered, 4, "2 first time, 2 on the retry");
491        assert_eq!(report.attempts, 2);
492
493        let batches = sink.batches.lock().unwrap();
494        assert_eq!(batches[0].len(), 4);
495        assert_eq!(batches[1].len(), 2, "only the failures are resent");
496        let resent: Vec<&str> = batches[1].iter().map(|d| d.id.as_str()).collect();
497        assert_eq!(resent, vec!["doc-1", "doc-3"]);
498    }
499
500    #[tokio::test]
501    async fn a_permanently_rejected_document_is_not_retried() {
502        let sink = MockSink::scripted(vec![BatchOutcome {
503            outcomes: vec![DocumentOutcome::Rejected, DocumentOutcome::Delivered],
504            transport_failure: false,
505            error: Some("403 forbidden".to_string()),
506        }]);
507
508        let report = deliver(&sink, documents(2), RetryPolicy::default(), no_sleep()).await;
509
510        assert_eq!(report.rejected, 1);
511        assert_eq!(report.delivered, 1);
512        assert_eq!(report.attempts, 1, "no point trying again");
513        assert!(!report.is_complete_success());
514        assert_eq!(report.error.as_deref(), Some("403 forbidden"));
515    }
516
517    #[tokio::test]
518    async fn retries_are_abandoned_once_the_policy_is_exhausted() {
519        let always_busy = BatchOutcome {
520            outcomes: vec![DocumentOutcome::Retryable, DocumentOutcome::Retryable],
521            transport_failure: false,
522            error: Some("429 too many requests".to_string()),
523        };
524        let sink = MockSink::scripted(vec![always_busy.clone(), always_busy.clone(), always_busy]);
525
526        let policy = RetryPolicy {
527            max_attempts: 3,
528            ..RetryPolicy::default()
529        };
530        let report = deliver(&sink, documents(2), policy, no_sleep()).await;
531
532        assert_eq!(report.attempts, 3);
533        assert_eq!(report.abandoned, 2);
534        assert_eq!(report.delivered, 0);
535        assert_eq!(sink.batch_count(), 3);
536    }
537
538    /// The whole batch may be replayed after a transport failure precisely because every document
539    /// carries a stable `_id`, so the second attempt cannot duplicate the first.
540    #[tokio::test]
541    async fn a_transport_failure_replays_the_whole_batch_with_identical_ids() {
542        let sink = MockSink::scripted(vec![BatchOutcome::transport_failure("connection reset")]);
543
544        let report = deliver(&sink, documents(3), RetryPolicy::default(), no_sleep()).await;
545
546        assert_eq!(report.delivered, 3);
547        assert_eq!(report.attempts, 2);
548
549        let batches = sink.batches.lock().unwrap();
550        let first: Vec<&str> = batches[0].iter().map(|d| d.id.as_str()).collect();
551        let second: Vec<&str> = batches[1].iter().map(|d| d.id.as_str()).collect();
552        assert_eq!(first, second, "a replay must reuse the same document ids");
553    }
554
555    #[tokio::test]
556    async fn a_batch_is_abandoned_when_transport_failures_persist() {
557        let sink = MockSink::scripted(vec![
558            BatchOutcome::transport_failure("refused"),
559            BatchOutcome::transport_failure("refused"),
560            BatchOutcome::transport_failure("refused"),
561        ]);
562
563        let report = deliver(&sink, documents(2), RetryPolicy::default(), no_sleep()).await;
564
565        assert_eq!(report.abandoned, 2);
566        assert_eq!(report.delivered, 0);
567        assert_eq!(report.error.as_deref(), Some("refused"));
568    }
569
570    /// A response with fewer entries than the batch says nothing about the tail; assuming success
571    /// there would silently lose documents.
572    #[tokio::test]
573    async fn an_unexplained_tail_is_retried_rather_than_assumed_delivered() {
574        let sink = MockSink::scripted(vec![BatchOutcome {
575            outcomes: vec![DocumentOutcome::Delivered],
576            transport_failure: false,
577            error: None,
578        }]);
579
580        let report = deliver(&sink, documents(3), RetryPolicy::default(), no_sleep()).await;
581
582        assert_eq!(report.delivered, 3);
583        let batches = sink.batches.lock().unwrap();
584        assert_eq!(batches[1].len(), 2);
585    }
586
587    #[test]
588    fn backoff_doubles_then_holds_at_the_ceiling() {
589        let policy = RetryPolicy {
590            max_attempts: 10,
591            initial_backoff: Duration::from_secs(2),
592            max_backoff: Duration::from_secs(10),
593        };
594
595        assert_eq!(policy.backoff_for(1), Duration::from_secs(2));
596        assert_eq!(policy.backoff_for(2), Duration::from_secs(4));
597        assert_eq!(policy.backoff_for(3), Duration::from_secs(8));
598        assert_eq!(policy.backoff_for(4), Duration::from_secs(10));
599        assert_eq!(policy.backoff_for(9), Duration::from_secs(10));
600    }
601}