use std::collections::VecDeque;
use std::time::Duration;
use futures::future::BoxFuture;
use super::document::ForwardDocument;
pub const DEFAULT_QUEUE_CAPACITY: usize = 10_000;
pub const DEFAULT_BATCH_DOCUMENTS: usize = 500;
pub const DEFAULT_BATCH_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentOutcome {
Delivered,
Retryable,
Rejected,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BatchOutcome {
pub outcomes: Vec<DocumentOutcome>,
pub transport_failure: bool,
pub error: Option<String>,
}
impl BatchOutcome {
#[must_use]
pub fn all_delivered(count: usize) -> Self {
Self {
outcomes: vec![DocumentOutcome::Delivered; count],
transport_failure: false,
error: None,
}
}
#[must_use]
pub fn transport_failure(error: impl Into<String>) -> Self {
Self {
outcomes: Vec::new(),
transport_failure: true,
error: Some(error.into()),
}
}
}
pub trait LogSink: Send + Sync + 'static {
fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome>;
fn describe(&self) -> String;
}
#[derive(Debug)]
pub struct DocumentQueue {
documents: VecDeque<ForwardDocument>,
capacity: usize,
dropped: u64,
}
impl DocumentQueue {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
documents: VecDeque::new(),
capacity: capacity.max(1),
dropped: 0,
}
}
pub fn push(&mut self, document: ForwardDocument) {
if self.documents.len() >= self.capacity {
self.documents.pop_front();
self.dropped += 1;
}
self.documents.push_back(document);
}
pub fn take_batch(&mut self, max_documents: usize, max_bytes: usize) -> Vec<ForwardDocument> {
let mut batch = Vec::new();
let mut bytes = 0;
while batch.len() < max_documents {
let Some(next) = self.documents.front() else {
break;
};
let next_bytes = next.approx_bytes();
if !batch.is_empty() && bytes + next_bytes > max_bytes {
break;
}
bytes += next_bytes;
batch.push(self.documents.pop_front().expect("front was just observed"));
}
batch
}
#[must_use]
pub fn len(&self) -> usize {
self.documents.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.documents.is_empty()
}
#[must_use]
pub fn dropped(&self) -> u64 {
self.dropped
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff: Duration::from_secs(2),
max_backoff: Duration::from_secs(30),
}
}
}
impl RetryPolicy {
#[must_use]
pub fn backoff_for(&self, attempt: u32) -> Duration {
let exponent = attempt.saturating_sub(1).min(16);
let scaled = self
.initial_backoff
.saturating_mul(2u32.saturating_pow(exponent));
scaled.min(self.max_backoff)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeliveryReport {
pub delivered: u64,
pub rejected: u64,
pub abandoned: u64,
pub attempts: u32,
pub error: Option<String>,
}
impl DeliveryReport {
#[must_use]
pub fn is_complete_success(&self) -> bool {
self.rejected == 0 && self.abandoned == 0
}
}
pub async fn deliver<S, F>(
sink: &S,
mut batch: Vec<ForwardDocument>,
policy: RetryPolicy,
mut sleep: F,
) -> DeliveryReport
where
S: LogSink + ?Sized,
F: FnMut(Duration) -> BoxFuture<'static, ()>,
{
let mut report = DeliveryReport::default();
for attempt in 1..=policy.max_attempts {
report.attempts = attempt;
let outcome = sink.send(&batch).await;
if outcome.transport_failure {
report.error = outcome.error;
if attempt < policy.max_attempts {
sleep(policy.backoff_for(attempt)).await;
continue;
}
report.abandoned += batch.len() as u64;
return report;
}
if outcome.error.is_some() {
report.error = outcome.error.clone();
}
let mut retry = Vec::new();
for (index, document) in batch.into_iter().enumerate() {
match outcome.outcomes.get(index) {
Some(DocumentOutcome::Delivered) => report.delivered += 1,
Some(DocumentOutcome::Rejected) => report.rejected += 1,
Some(DocumentOutcome::Retryable) => retry.push(document),
None => retry.push(document),
}
}
if retry.is_empty() {
return report;
}
batch = retry;
if attempt < policy.max_attempts {
sleep(policy.backoff_for(attempt)).await;
}
}
report.abandoned += batch.len() as u64;
report
}
#[cfg(test)]
pub mod mock {
use super::*;
use std::sync::{Arc, Mutex};
pub struct MockSink {
pub batches: Mutex<Vec<Vec<ForwardDocument>>>,
responses: Mutex<VecDeque<BatchOutcome>>,
block_until: Option<Arc<tokio::sync::Notify>>,
pub completed: Mutex<usize>,
}
impl MockSink {
#[must_use]
pub fn accepting() -> Self {
Self {
batches: Mutex::new(Vec::new()),
responses: Mutex::new(VecDeque::new()),
block_until: None,
completed: Mutex::new(0),
}
}
#[must_use]
pub fn scripted(responses: Vec<BatchOutcome>) -> Self {
Self {
batches: Mutex::new(Vec::new()),
responses: Mutex::new(responses.into()),
block_until: None,
completed: Mutex::new(0),
}
}
#[must_use]
pub fn blocking(release: Arc<tokio::sync::Notify>) -> Self {
Self {
batches: Mutex::new(Vec::new()),
responses: Mutex::new(VecDeque::new()),
block_until: Some(release),
completed: Mutex::new(0),
}
}
#[must_use]
pub fn completed_count(&self) -> usize {
*self.completed.lock().unwrap()
}
#[must_use]
pub fn submitted_ids(&self) -> Vec<String> {
self.batches
.lock()
.unwrap()
.iter()
.flat_map(|batch| batch.iter().map(|d| d.id.clone()))
.collect()
}
#[must_use]
pub fn batch_count(&self) -> usize {
self.batches.lock().unwrap().len()
}
}
impl LogSink for MockSink {
fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome> {
Box::pin(async move {
self.batches.lock().unwrap().push(batch.to_vec());
if let Some(release) = &self.block_until {
release.notified().await;
}
*self.completed.lock().unwrap() += 1;
self.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| BatchOutcome::all_delivered(batch.len()))
})
}
fn describe(&self) -> String {
"mock".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::mock::MockSink;
use super::*;
use crate::node::daemon::forward::document::DocumentSource;
fn document(id: &str) -> ForwardDocument {
ForwardDocument {
id: id.to_string(),
index: "beta-nodes-2026.08.19".to_string(),
source: DocumentSource {
timestamp: "2026-08-19T20:50:00.000000Z".to_string(),
level: "INFO".to_string(),
target: None,
message: "m".to_string(),
node_id: "7".to_string(),
service: "node7".to_string(),
binary_version: "0.17.2".to_string(),
channel: "beta".to_string(),
os: "linux".to_string(),
arch: "x86_64".to_string(),
peer_id: None,
version: None,
commit: None,
},
}
}
fn documents(count: usize) -> Vec<ForwardDocument> {
(0..count).map(|i| document(&format!("doc-{i}"))).collect()
}
fn no_sleep() -> impl FnMut(Duration) -> BoxFuture<'static, ()> {
|_| Box::pin(async {})
}
#[test]
fn a_full_queue_drops_the_oldest_not_the_newest() {
let mut queue = DocumentQueue::new(3);
for i in 0..5 {
queue.push(document(&format!("doc-{i}")));
}
assert_eq!(queue.len(), 3);
assert_eq!(queue.dropped(), 2);
let batch = queue.take_batch(10, usize::MAX);
let ids: Vec<&str> = batch.iter().map(|d| d.id.as_str()).collect();
assert_eq!(
ids,
vec!["doc-2", "doc-3", "doc-4"],
"the recent events are the ones worth keeping"
);
}
#[test]
fn a_batch_is_bounded_by_document_count() {
let mut queue = DocumentQueue::new(100);
for doc in documents(10) {
queue.push(doc);
}
assert_eq!(queue.take_batch(4, usize::MAX).len(), 4);
assert_eq!(queue.len(), 6);
}
#[test]
fn a_batch_is_bounded_by_approximate_size() {
let mut queue = DocumentQueue::new(100);
for doc in documents(10) {
queue.push(doc);
}
let one = document("sizing").approx_bytes();
let batch = queue.take_batch(100, one * 3);
assert_eq!(batch.len(), 3);
}
#[test]
fn an_oversized_document_cannot_wedge_the_queue() {
let mut queue = DocumentQueue::new(10);
queue.push(document("huge"));
let batch = queue.take_batch(100, 1);
assert_eq!(batch.len(), 1);
assert!(queue.is_empty());
}
#[tokio::test]
async fn a_clean_batch_is_delivered_in_one_attempt() {
let sink = MockSink::accepting();
let report = deliver(&sink, documents(3), RetryPolicy::default(), no_sleep()).await;
assert_eq!(report.delivered, 3);
assert_eq!(report.attempts, 1);
assert!(report.is_complete_success());
assert_eq!(sink.batch_count(), 1);
}
#[tokio::test]
async fn only_the_retryable_positions_are_resent() {
let sink = MockSink::scripted(vec![BatchOutcome {
outcomes: vec![
DocumentOutcome::Delivered,
DocumentOutcome::Retryable,
DocumentOutcome::Delivered,
DocumentOutcome::Retryable,
],
transport_failure: false,
error: None,
}]);
let report = deliver(&sink, documents(4), RetryPolicy::default(), no_sleep()).await;
assert_eq!(report.delivered, 4, "2 first time, 2 on the retry");
assert_eq!(report.attempts, 2);
let batches = sink.batches.lock().unwrap();
assert_eq!(batches[0].len(), 4);
assert_eq!(batches[1].len(), 2, "only the failures are resent");
let resent: Vec<&str> = batches[1].iter().map(|d| d.id.as_str()).collect();
assert_eq!(resent, vec!["doc-1", "doc-3"]);
}
#[tokio::test]
async fn a_permanently_rejected_document_is_not_retried() {
let sink = MockSink::scripted(vec![BatchOutcome {
outcomes: vec![DocumentOutcome::Rejected, DocumentOutcome::Delivered],
transport_failure: false,
error: Some("403 forbidden".to_string()),
}]);
let report = deliver(&sink, documents(2), RetryPolicy::default(), no_sleep()).await;
assert_eq!(report.rejected, 1);
assert_eq!(report.delivered, 1);
assert_eq!(report.attempts, 1, "no point trying again");
assert!(!report.is_complete_success());
assert_eq!(report.error.as_deref(), Some("403 forbidden"));
}
#[tokio::test]
async fn retries_are_abandoned_once_the_policy_is_exhausted() {
let always_busy = BatchOutcome {
outcomes: vec![DocumentOutcome::Retryable, DocumentOutcome::Retryable],
transport_failure: false,
error: Some("429 too many requests".to_string()),
};
let sink = MockSink::scripted(vec![always_busy.clone(), always_busy.clone(), always_busy]);
let policy = RetryPolicy {
max_attempts: 3,
..RetryPolicy::default()
};
let report = deliver(&sink, documents(2), policy, no_sleep()).await;
assert_eq!(report.attempts, 3);
assert_eq!(report.abandoned, 2);
assert_eq!(report.delivered, 0);
assert_eq!(sink.batch_count(), 3);
}
#[tokio::test]
async fn a_transport_failure_replays_the_whole_batch_with_identical_ids() {
let sink = MockSink::scripted(vec![BatchOutcome::transport_failure("connection reset")]);
let report = deliver(&sink, documents(3), RetryPolicy::default(), no_sleep()).await;
assert_eq!(report.delivered, 3);
assert_eq!(report.attempts, 2);
let batches = sink.batches.lock().unwrap();
let first: Vec<&str> = batches[0].iter().map(|d| d.id.as_str()).collect();
let second: Vec<&str> = batches[1].iter().map(|d| d.id.as_str()).collect();
assert_eq!(first, second, "a replay must reuse the same document ids");
}
#[tokio::test]
async fn a_batch_is_abandoned_when_transport_failures_persist() {
let sink = MockSink::scripted(vec![
BatchOutcome::transport_failure("refused"),
BatchOutcome::transport_failure("refused"),
BatchOutcome::transport_failure("refused"),
]);
let report = deliver(&sink, documents(2), RetryPolicy::default(), no_sleep()).await;
assert_eq!(report.abandoned, 2);
assert_eq!(report.delivered, 0);
assert_eq!(report.error.as_deref(), Some("refused"));
}
#[tokio::test]
async fn an_unexplained_tail_is_retried_rather_than_assumed_delivered() {
let sink = MockSink::scripted(vec![BatchOutcome {
outcomes: vec![DocumentOutcome::Delivered],
transport_failure: false,
error: None,
}]);
let report = deliver(&sink, documents(3), RetryPolicy::default(), no_sleep()).await;
assert_eq!(report.delivered, 3);
let batches = sink.batches.lock().unwrap();
assert_eq!(batches[1].len(), 2);
}
#[test]
fn backoff_doubles_then_holds_at_the_ceiling() {
let policy = RetryPolicy {
max_attempts: 10,
initial_backoff: Duration::from_secs(2),
max_backoff: Duration::from_secs(10),
};
assert_eq!(policy.backoff_for(1), Duration::from_secs(2));
assert_eq!(policy.backoff_for(2), Duration::from_secs(4));
assert_eq!(policy.backoff_for(3), Duration::from_secs(8));
assert_eq!(policy.backoff_for(4), Duration::from_secs(10));
assert_eq!(policy.backoff_for(9), Duration::from_secs(10));
}
}