1use std::collections::VecDeque;
15use std::time::Duration;
16
17use futures::future::BoxFuture;
18
19use super::document::ForwardDocument;
20
21pub const DEFAULT_QUEUE_CAPACITY: usize = 10_000;
26
27pub const DEFAULT_BATCH_DOCUMENTS: usize = 500;
29
30pub const DEFAULT_BATCH_BYTES: usize = 4 * 1024 * 1024;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum DocumentOutcome {
37 Delivered,
39 Retryable,
41 Rejected,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct BatchOutcome {
48 pub outcomes: Vec<DocumentOutcome>,
51 pub transport_failure: bool,
54 pub error: Option<String>,
56}
57
58impl BatchOutcome {
59 #[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 #[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
80pub trait LogSink: Send + Sync + 'static {
85 fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome>;
86
87 fn describe(&self) -> String;
89}
90
91#[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 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 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 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 #[must_use]
155 pub fn dropped(&self) -> u64 {
156 self.dropped
157 }
158}
159
160#[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
192pub struct DeliveryReport {
193 pub delivered: u64,
194 pub rejected: u64,
196 pub abandoned: u64,
198 pub attempts: u32,
199 pub error: Option<String>,
200}
201
202impl DeliveryReport {
203 #[must_use]
205 pub fn is_complete_success(&self) -> bool {
206 self.rejected == 0 && self.abandoned == 0
207 }
208}
209
210pub 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 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 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#[cfg(test)]
274pub mod mock {
275 use super::*;
276 use std::sync::{Arc, Mutex};
277
278 pub struct MockSink {
280 pub batches: Mutex<Vec<Vec<ForwardDocument>>>,
281 responses: Mutex<VecDeque<BatchOutcome>>,
282 block_until: Option<Arc<tokio::sync::Notify>>,
285 pub completed: Mutex<usize>,
287 }
288
289 impl MockSink {
290 #[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 #[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 #[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 #[must_use]
325 pub fn completed_count(&self) -> usize {
326 *self.completed.lock().unwrap()
327 }
328
329 #[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 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 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 #[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 #[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 #[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 #[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}