use std::error;
use std::marker::PhantomData;
use time::OffsetDateTime;
use tracing::{debug, warn};
use super::FlushPolicy;
use crate::sink::{Sink, WindowMeta};
use crate::store::{Segment as _, Store};
#[derive(Debug, thiserror::Error)]
pub enum TierError<St, S>
where
St: error::Error + Send + Sync + 'static,
S: error::Error + Send + Sync + 'static,
{
#[error("store error: {0}")]
Store(#[source] St),
#[error("downstream sink error: {0}")]
Downstream(#[source] S),
}
pub struct Tier<R, St, S> {
store: St,
policy: FlushPolicy,
inner: S,
pipeline: Option<String>,
initialized: bool,
active_window: Option<i64>,
active_count: usize,
_record: PhantomData<fn() -> R>,
}
impl<R, St, S> Tier<R, St, S> {
#[must_use]
pub fn new(store: St, policy: FlushPolicy, inner: S) -> Self {
Self {
store,
policy,
inner,
pipeline: None,
initialized: false,
active_window: None,
active_count: 0,
_record: PhantomData,
}
}
#[must_use]
pub fn with_pipeline_name(mut self, name: impl Into<String>) -> Self {
self.pipeline = Some(name.into());
self
}
#[must_use]
pub fn inner(&self) -> &S {
&self.inner
}
fn window_secs(&self) -> i64 {
i64::try_from(self.policy.every.as_secs())
.unwrap_or(i64::MAX)
.max(1)
}
fn align(&self, unix: i64) -> i64 {
unix - unix.rem_euclid(self.window_secs())
}
}
impl<R, St, S> Tier<R, St, S>
where
R: Send + 'static,
St: Store<R>,
S: Sink<R>,
{
fn pipeline_name(&self) -> String {
self.pipeline
.as_deref()
.or_else(|| self.store.pipeline_hint())
.unwrap_or("unknown")
.to_owned()
}
async fn ensure_init(&mut self) {
if self.initialized {
return;
}
self.initialized = true;
if let Err(error) = self.drain(true).await {
warn!(
pipeline = %self.pipeline_name(),
%error,
"startup replay failed; stored windows retained for retry"
);
}
}
async fn drain(&mut self, include_active: bool) -> Result<(), TierError<St::Error, S::Error>> {
let pipeline = self.pipeline_name();
let window_secs = self.window_secs();
let mut cursor = None;
let mut first_error = None;
loop {
let Some(mut seg) = self.store.oldest(cursor).await.map_err(TierError::Store)? else {
break;
};
let window = seg.window();
if !include_active && Some(window) == self.active_window {
cursor = Some(window);
continue;
}
let records = match seg.records().await {
Ok(records) => records,
Err(error) => {
warn!(
pipeline = %pipeline,
window,
%error,
"failed to read stored window; retained for retry"
);
first_error.get_or_insert(TierError::Store(error));
cursor = Some(window);
continue;
}
};
if !records.is_empty() {
let start = OffsetDateTime::from_unix_timestamp(window)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
let meta = WindowMeta {
pipeline: pipeline.clone(),
start,
end: start.saturating_add(time::Duration::seconds(window_secs)),
};
debug!(
pipeline = %pipeline,
window,
records = records.len(),
"tier draining window downstream"
);
if let Err(error) = self.inner.ingest(&meta, records).await {
warn!(
pipeline = %pipeline,
window,
%error,
"downstream rejected window; retained for retry"
);
first_error.get_or_insert(TierError::Downstream(error));
cursor = Some(window);
continue;
}
}
if let Err(error) = seg.commit().await {
warn!(
pipeline = %pipeline,
window,
%error,
"failed to remove drained window from store"
);
first_error.get_or_insert(TierError::Store(error));
cursor = Some(window);
continue;
}
if Some(window) == self.active_window {
self.active_window = None;
self.active_count = 0;
}
}
match first_error {
None => Ok(()),
Some(error) => Err(error),
}
}
}
impl<R, St, S> Sink<R> for Tier<R, St, S>
where
R: Send + 'static,
St: Store<R>,
S: Sink<R>,
{
type Error = TierError<St::Error, S::Error>;
async fn ingest(&mut self, meta: &WindowMeta, records: Vec<R>) -> Result<(), Self::Error> {
if self.pipeline.is_none() {
self.pipeline = Some(meta.pipeline.clone());
}
self.ensure_init().await;
if !records.is_empty() {
let window = self.align(meta.end.unix_timestamp());
let count = records.len();
self.store
.append(window, records)
.await
.map_err(TierError::Store)?;
if self.active_window != Some(window) {
self.active_window = Some(window);
self.active_count = 0;
}
self.active_count += count;
}
if self.active_count >= self.policy.max_records {
self.drain(true).await
} else {
self.drain(false).await
}
}
async fn flush(&mut self) -> Result<(), Self::Error> {
self.initialized = true;
self.drain(true).await?;
self.inner.flush().await.map_err(TierError::Downstream)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use super::*;
use crate::store::MemStore;
use crate::test_util::{SharedSink, TestSinkFailure, meta_at};
struct PoisonSink {
inner: SharedSink<i32>,
armed: Arc<AtomicBool>,
}
impl Sink<i32> for PoisonSink {
type Error = TestSinkFailure;
async fn ingest(
&mut self,
meta: &WindowMeta,
records: Vec<i32>,
) -> Result<(), TestSinkFailure> {
if self.armed.load(Ordering::SeqCst) && records.contains(&13) {
return Err(TestSinkFailure);
}
self.inner.ingest(meta, records).await
}
async fn flush(&mut self) -> Result<(), TestSinkFailure> {
self.inner.flush().await
}
}
#[tokio::test]
async fn mem_tier_holds_until_max_records() {
let inner = SharedSink::new();
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::new(Duration::from_secs(3600), 3),
inner.clone(),
);
sink.ingest(&meta_at("p", 10), vec![1, 2]).await.unwrap();
assert!(inner.batches().is_empty());
sink.ingest(&meta_at("p", 20), vec![3]).await.unwrap();
let batches = inner.batches();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].1, vec![1, 2, 3]);
}
#[tokio::test]
async fn time_unbounded_policy_drains_on_record_count() {
let inner = SharedSink::new();
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::new(Duration::MAX, 2),
inner.clone(),
);
sink.ingest(&meta_at("p", 10), vec![1]).await.unwrap();
assert!(inner.batches().is_empty());
sink.ingest(&meta_at("p", 20), vec![2]).await.unwrap();
let batches = inner.batches();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].0.start.unix_timestamp(), 0);
assert_eq!(
batches[0].0.end,
batches[0]
.0
.start
.saturating_add(time::Duration::seconds(i64::MAX))
);
assert_eq!(batches[0].1, vec![1, 2]);
}
#[tokio::test]
async fn mem_tier_drains_closed_window_on_next_ingest() {
let inner = SharedSink::new();
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::every(Duration::from_secs(300)),
inner.clone(),
);
sink.ingest(&meta_at("p", 10), vec![1]).await.unwrap();
assert!(inner.batches().is_empty());
sink.ingest(&meta_at("p", 310), vec![2]).await.unwrap();
let batches = inner.batches();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].0.pipeline, "p");
assert_eq!(batches[0].0.start.unix_timestamp(), 0);
assert_eq!(batches[0].0.end.unix_timestamp(), 300);
assert_eq!(batches[0].1, vec![1]);
}
#[tokio::test]
async fn mem_tier_retains_records_across_failing_downstream() {
let inner = SharedSink::new();
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::new(Duration::from_secs(3600), 2),
inner.clone(),
);
inner.set_fail(true);
assert!(sink.ingest(&meta_at("p", 10), vec![1, 2]).await.is_err());
assert!(inner.batches().is_empty());
inner.set_fail(false);
sink.ingest(&meta_at("p", 20), vec![3]).await.unwrap();
let batches = inner.batches();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].1, vec![1, 2, 3]);
}
#[tokio::test]
async fn flush_drains_the_whole_stack() {
let bottom = SharedSink::new();
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::hourly(),
Tier::new(MemStore::new(), FlushPolicy::hourly(), bottom.clone()),
);
sink.ingest(&meta_at("p", 10), vec![1, 2, 3]).await.unwrap();
assert!(bottom.batches().is_empty());
sink.flush().await.unwrap();
assert_eq!(bottom.batches()[0].1, vec![1, 2, 3]);
assert!(bottom.flushed());
}
#[tokio::test]
async fn poison_window_does_not_block_newer_windows() {
let inner = SharedSink::new();
let armed = Arc::new(AtomicBool::new(true));
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::every(Duration::from_secs(300)),
PoisonSink {
inner: inner.clone(),
armed: Arc::clone(&armed),
},
);
sink.ingest(&meta_at("p", 10), vec![13]).await.unwrap();
assert!(sink.ingest(&meta_at("p", 310), vec![2]).await.is_err());
assert!(inner.batches().is_empty());
assert!(sink.ingest(&meta_at("p", 610), vec![3]).await.is_err());
let batches = inner.batches();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].0.start.unix_timestamp(), 300);
assert_eq!(batches[0].1, vec![2]);
armed.store(false, Ordering::SeqCst);
sink.flush().await.unwrap();
let batches = inner.batches();
assert_eq!(batches.len(), 3);
assert_eq!(batches[1].0.start.unix_timestamp(), 0);
assert_eq!(batches[1].1, vec![13]);
assert_eq!(batches[2].0.start.unix_timestamp(), 600);
assert_eq!(batches[2].1, vec![3]);
}
#[tokio::test]
async fn outage_retains_all_windows_and_recovers_in_order() {
let inner = SharedSink::new();
let mut sink = Tier::new(
MemStore::new(),
FlushPolicy::every(Duration::from_secs(300)),
inner.clone(),
);
sink.ingest(&meta_at("p", 10), vec![1]).await.unwrap();
inner.set_fail(true);
assert!(sink.ingest(&meta_at("p", 310), vec![2]).await.is_err());
assert!(sink.ingest(&meta_at("p", 610), vec![3]).await.is_err());
assert!(inner.batches().is_empty());
inner.set_fail(false);
sink.flush().await.unwrap();
let starts = inner
.batches()
.iter()
.map(|(meta, records)| (meta.start.unix_timestamp(), records.clone()))
.collect::<Vec<_>>();
assert_eq!(starts, vec![(0, vec![1]), (300, vec![2]), (600, vec![3])]);
}
}