use async_trait::async_trait;
use bytes::Bytes;
use futures_core::Stream;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
pub const RESUME_RING_CAP: usize = 256;
pub const RESUME_IDLE_TTL: Duration = Duration::from_secs(5 * 60);
pub const RESUME_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
pub const RESUME_TERMINAL_GRACE: Duration = Duration::from_secs(30);
#[async_trait]
pub trait ResumeBuffer: Send + Sync {
async fn record(
&self,
stream_id: &str,
event_id: u64,
payload: Bytes,
) -> Result<(), ResumeError>;
async fn replay(
&self,
stream_id: &str,
since_id: u64,
) -> Result<Pin<Box<dyn Stream<Item = (u64, Bytes)> + Send>>, ResumeError>;
async fn close(&self, stream_id: &str) -> Result<(), ResumeError>;
async fn is_terminal(&self, _stream_id: &str) -> Result<bool, ResumeError> {
Ok(false)
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ResumeError {
#[error("resume window expired (oldest retained id > requested {since_id})")]
Expired {
since_id: u64,
},
#[error("stream not found: {0}")]
NotFound(String),
#[error("buffer backend: {0}")]
Backend(#[source] Box<dyn std::error::Error + Send + Sync>),
}
pub struct NoopResumeBuffer;
#[async_trait]
impl ResumeBuffer for NoopResumeBuffer {
async fn record(
&self,
_stream_id: &str,
_event_id: u64,
_payload: Bytes,
) -> Result<(), ResumeError> {
Ok(())
}
async fn replay(
&self,
stream_id: &str,
_since_id: u64,
) -> Result<Pin<Box<dyn Stream<Item = (u64, Bytes)> + Send>>, ResumeError> {
Err(ResumeError::NotFound(stream_id.to_string()))
}
async fn close(&self, _stream_id: &str) -> Result<(), ResumeError> {
Ok(())
}
}
use crate::bus::KvStore;
use crate::error::BusError;
const RESUME_BUCKET_PREFIX: &str = "klieo.resume.";
const RESUME_DIRECTORY_BUCKET: &str = "klieo.resume.index";
const META_KEY: &str = "meta";
const EVENT_KEY_PREFIX: &str = "event/";
#[derive(Debug, Clone)]
pub struct SweeperTuning {
pub idle_ttl: Duration,
pub sweep_interval: Duration,
pub terminal_grace: Duration,
}
impl Default for SweeperTuning {
fn default() -> Self {
Self {
idle_ttl: RESUME_IDLE_TTL,
sweep_interval: RESUME_SWEEP_INTERVAL,
terminal_grace: RESUME_TERMINAL_GRACE,
}
}
}
pub struct KvResumeBuffer {
kv: Arc<dyn KvStore>,
tuning: SweeperTuning,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub(crate) struct BufferMeta {
pub next_id: u64,
pub oldest_id: u64,
pub last_write_unix_ms: u64,
pub terminal: bool,
}
impl KvResumeBuffer {
pub fn new(kv: Arc<dyn KvStore>) -> Self {
Self {
kv,
tuning: SweeperTuning::default(),
}
}
pub fn with_tuning(kv: Arc<dyn KvStore>, tuning: SweeperTuning) -> Self {
Self { kv, tuning }
}
fn bucket(stream_id: &str) -> String {
format!("{RESUME_BUCKET_PREFIX}{stream_id}")
}
fn event_key(id: u64) -> String {
format!("{EVENT_KEY_PREFIX}{id}")
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub(crate) async fn read_meta(&self, stream_id: &str) -> Result<BufferMeta, ResumeError> {
let bucket = Self::bucket(stream_id);
let entry = self
.kv
.get(&bucket, META_KEY)
.await
.map_err(|e| ResumeError::Backend(Box::new(e)))?;
match entry {
Some(entry) => serde_json::from_slice::<BufferMeta>(&entry.value)
.map_err(|e| ResumeError::Backend(Box::new(e))),
None => Err(ResumeError::NotFound(stream_id.to_string())),
}
}
async fn write_meta(&self, stream_id: &str, meta: &BufferMeta) -> Result<(), ResumeError> {
let bucket = Self::bucket(stream_id);
let bytes = serde_json::to_vec(meta).map_err(|e| ResumeError::Backend(Box::new(e)))?;
self.kv
.put(&bucket, META_KEY, Bytes::from(bytes))
.await
.map_err(|e| ResumeError::Backend(Box::new(e)))?;
Ok(())
}
}
#[async_trait]
impl ResumeBuffer for KvResumeBuffer {
#[tracing::instrument(
skip_all,
fields(
db.system = "klieo-kv",
db.namespace = "klieo-resume",
db.operation = "record",
klieo.resume.stream_id = %stream_id,
klieo.resume.event_id = event_id,
),
level = "debug",
err,
)]
async fn record(
&self,
stream_id: &str,
event_id: u64,
payload: Bytes,
) -> Result<(), ResumeError> {
let bucket = Self::bucket(stream_id);
let mut meta = match self.read_meta(stream_id).await {
Ok(m) => m,
Err(ResumeError::NotFound(_)) => BufferMeta {
next_id: event_id,
oldest_id: event_id,
last_write_unix_ms: Self::now_unix_ms(),
terminal: false,
},
Err(other) => return Err(other),
};
self.kv
.put(&bucket, &Self::event_key(event_id), payload)
.await
.map_err(|e| ResumeError::Backend(Box::new(e)))?;
if let Err(e) = self
.kv
.put(RESUME_DIRECTORY_BUCKET, stream_id, Bytes::from_static(b"1"))
.await
{
tracing::warn!(
target: "klieo.resume",
stream_id,
error = %e,
"resume directory index write failed; sweeper may miss this stream"
);
}
if event_id >= meta.next_id {
meta.next_id = event_id + 1;
}
while meta.next_id.saturating_sub(meta.oldest_id) > RESUME_RING_CAP as u64 {
let to_evict = meta.oldest_id;
let _ = self.kv.delete(&bucket, &Self::event_key(to_evict)).await;
meta.oldest_id = to_evict + 1;
}
meta.last_write_unix_ms = Self::now_unix_ms();
self.write_meta(stream_id, &meta).await
}
#[tracing::instrument(
skip_all,
fields(
db.system = "klieo-kv",
db.namespace = "klieo-resume",
db.operation = "replay",
klieo.resume.stream_id = %stream_id,
klieo.resume.since_id = since_id,
),
level = "debug",
err,
)]
async fn replay(
&self,
stream_id: &str,
since_id: u64,
) -> Result<Pin<Box<dyn Stream<Item = (u64, Bytes)> + Send>>, ResumeError> {
let meta = self.read_meta(stream_id).await?;
if since_id + 1 < meta.oldest_id {
return Err(ResumeError::Expired { since_id });
}
let start = since_id + 1;
let end = meta.next_id;
let bucket = Self::bucket(stream_id);
let mut events: Vec<(u64, Bytes)> = Vec::new();
for id in start..end {
let key = Self::event_key(id);
match self.kv.get(&bucket, &key).await {
Ok(Some(entry)) => events.push((id, entry.value)),
Ok(None) => {}
Err(e) => return Err(ResumeError::Backend(Box::new(e))),
}
}
Ok(Box::pin(tokio_stream::iter(events)))
}
#[tracing::instrument(
skip_all,
fields(
db.system = "klieo-kv",
db.namespace = "klieo-resume",
db.operation = "close",
klieo.resume.stream_id = %stream_id,
),
level = "debug",
err,
)]
async fn close(&self, stream_id: &str) -> Result<(), ResumeError> {
let mut meta = self.read_meta(stream_id).await?;
meta.terminal = true;
self.write_meta(stream_id, &meta).await
}
#[tracing::instrument(
skip_all,
fields(
db.system = "klieo-kv",
db.namespace = "klieo-resume",
db.operation = "is_terminal",
klieo.resume.stream_id = %stream_id,
),
level = "debug",
err,
)]
async fn is_terminal(&self, stream_id: &str) -> Result<bool, ResumeError> {
match self.read_meta(stream_id).await {
Ok(meta) => Ok(meta.terminal),
Err(ResumeError::NotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
}
impl KvResumeBuffer {
pub fn spawn_sweeper(
self: Arc<Self>,
cancel: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut tick = tokio::time::interval(self.tuning.sweep_interval);
tick.tick().await;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => break,
_ = tick.tick() => {
if let Err(e) = self.sweep_once().await {
tracing::warn!(
target: "klieo.resume.sweeper",
error = %e,
"resume buffer sweep failed; will retry next interval"
);
}
}
}
}
})
}
async fn sweep_once(&self) -> Result<(), BusError> {
let stream_ids = match self.kv.keys(RESUME_DIRECTORY_BUCKET).await {
Ok(ids) => ids,
Err(BusError::Unsupported(_)) => {
tracing::debug!(
target: "klieo.resume.sweeper",
"KvStore backend does not support keys(); proactive eviction disabled"
);
return Ok(());
}
Err(e) => return Err(e),
};
let now_ms = Self::now_unix_ms();
for stream_id in stream_ids {
let meta = match self.read_meta(&stream_id).await {
Ok(m) => m,
Err(ResumeError::NotFound(_)) => {
let _ = self.kv.delete(RESUME_DIRECTORY_BUCKET, &stream_id).await;
continue;
}
Err(other) => {
tracing::warn!(
target: "klieo.resume.sweeper",
stream_id,
error = %other,
"read_meta failed during sweep; skipping this stream this interval"
);
continue;
}
};
let age_ms = now_ms.saturating_sub(meta.last_write_unix_ms);
let evict = if meta.terminal {
age_ms >= self.tuning.terminal_grace.as_millis() as u64
} else {
age_ms >= self.tuning.idle_ttl.as_millis() as u64
};
if evict {
self.evict_bucket(&stream_id).await?;
}
}
Ok(())
}
async fn evict_bucket(&self, stream_id: &str) -> Result<(), BusError> {
let bucket = Self::bucket(stream_id);
let keys = match self.kv.keys(&bucket).await {
Ok(k) => k,
Err(BusError::Unsupported(_)) => return Ok(()),
Err(e) => return Err(e),
};
let mut delete_failures: usize = 0;
for k in keys {
if let Err(e) = self.kv.delete(&bucket, &k).await {
delete_failures += 1;
tracing::warn!(
target: "klieo.resume.sweeper",
stream_id,
key = %k,
error = %e,
"evict delete failed; will retry next interval"
);
}
}
if delete_failures == 0 {
let _ = self.kv.delete(RESUME_DIRECTORY_BUCKET, stream_id).await;
} else {
tracing::warn!(
target: "klieo.resume.sweeper",
stream_id,
failures = delete_failures,
"directory entry retained for retry; partial eviction"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_record_is_ok() {
let buf = NoopResumeBuffer;
buf.record("s1", 1, Bytes::from_static(b"hi"))
.await
.unwrap();
}
#[tokio::test]
async fn noop_replay_returns_not_found() {
let buf = NoopResumeBuffer;
let result = buf.replay("s1", 0).await;
match result {
Err(ResumeError::NotFound(s)) => assert_eq!(s, "s1"),
Err(other) => panic!("expected NotFound, got {other:?}"),
Ok(_) => panic!("expected Err, got Ok"),
}
}
#[tokio::test]
async fn noop_close_is_ok() {
let buf = NoopResumeBuffer;
buf.close("s1").await.unwrap();
}
#[test]
fn backend_error_preserves_source_chain() {
use std::error::Error as _;
let inner = std::io::Error::other("disk gone");
let e = ResumeError::Backend(Box::new(inner));
assert_eq!(e.to_string(), "buffer backend: disk gone");
let src = e
.source()
.expect("Backend must preserve source for e.source()");
assert_eq!(src.to_string(), "disk gone");
}
#[test]
fn expired_renders_since_id() {
let e = ResumeError::Expired { since_id: 42 };
assert_eq!(
e.to_string(),
"resume window expired (oldest retained id > requested 42)"
);
}
fn in_mem_buffer() -> KvResumeBuffer {
use crate::test_utils::fake_kv;
KvResumeBuffer::new(fake_kv())
}
#[tokio::test]
async fn kv_record_and_replay_in_order() {
let buf = in_mem_buffer();
for i in 1..=5u64 {
buf.record("s1", i, Bytes::from(format!("e{i}")))
.await
.unwrap();
}
let stream = buf.replay("s1", 2).await.expect("replay");
use tokio_stream::StreamExt as _;
let mut stream = stream;
let mut got = Vec::new();
while let Some((id, bytes)) = stream.next().await {
got.push((id, String::from_utf8(bytes.to_vec()).unwrap()));
}
assert_eq!(
got,
vec![(3, "e3".into()), (4, "e4".into()), (5, "e5".into())]
);
}
#[tokio::test]
async fn kv_ring_evicts_oldest_when_full() {
let buf = in_mem_buffer();
for i in 1..=300u64 {
buf.record("s1", i, Bytes::from(format!("e{i}")))
.await
.unwrap();
}
let _ = buf.replay("s1", 44).await.expect("replay since 44");
match buf.replay("s1", 5).await {
Err(ResumeError::Expired { since_id }) => assert_eq!(since_id, 5),
Err(other) => panic!("expected Expired, got Err({other:?})"),
Ok(_) => panic!("expected Expired, got Ok(stream)"),
}
}
#[tokio::test]
async fn kv_replay_unknown_stream_returns_not_found() {
let buf = in_mem_buffer();
match buf.replay("missing", 0).await {
Err(ResumeError::NotFound(s)) => assert_eq!(s, "missing"),
Err(other) => panic!("expected NotFound, got Err({other:?})"),
Ok(_) => panic!("expected NotFound, got Ok(stream)"),
}
}
#[tokio::test]
async fn kv_close_marks_terminal_in_meta() {
let buf = in_mem_buffer();
buf.record("s1", 1, Bytes::from_static(b"x")).await.unwrap();
buf.close("s1").await.unwrap();
let meta = buf.read_meta("s1").await.unwrap();
assert!(meta.terminal);
}
#[tokio::test]
async fn is_terminal_false_before_close() {
let buf = in_mem_buffer();
buf.record("s-1", 1, Bytes::from_static(b"hi"))
.await
.unwrap();
assert!(!buf.is_terminal("s-1").await.unwrap());
}
#[tokio::test]
async fn is_terminal_true_after_close() {
let buf = in_mem_buffer();
buf.record("s-1", 1, Bytes::from_static(b"hi"))
.await
.unwrap();
buf.close("s-1").await.unwrap();
assert!(buf.is_terminal("s-1").await.unwrap());
}
#[tokio::test]
async fn is_terminal_false_on_unknown_id() {
let buf = in_mem_buffer();
assert!(!buf.is_terminal("never").await.unwrap());
}
fn fast_sweeper_buffer() -> KvResumeBuffer {
use crate::test_utils::fake_kv;
KvResumeBuffer::with_tuning(
fake_kv(),
SweeperTuning {
idle_ttl: Duration::from_millis(100),
sweep_interval: Duration::from_millis(20),
terminal_grace: Duration::from_millis(50),
},
)
}
#[tokio::test]
async fn sweeper_evicts_idle_buckets_after_ttl() {
let buf = Arc::new(fast_sweeper_buffer());
buf.record("s1", 1, Bytes::from_static(b"x")).await.unwrap();
assert!(buf.read_meta("s1").await.is_ok());
let cancel = tokio_util::sync::CancellationToken::new();
let sweep_handle = buf.clone().spawn_sweeper(cancel.clone());
tokio::time::sleep(Duration::from_millis(300)).await;
match buf.read_meta("s1").await {
Err(ResumeError::NotFound(_)) => {}
Ok(_) => panic!("sweeper did not evict idle bucket"),
Err(other) => panic!("unexpected error: {other:?}"),
}
cancel.cancel();
let _ = tokio::time::timeout(Duration::from_secs(1), sweep_handle).await;
}
#[tokio::test]
async fn sweeper_evicts_terminal_buckets_after_grace() {
let buf = Arc::new(fast_sweeper_buffer());
buf.record("s1", 1, Bytes::from_static(b"x")).await.unwrap();
buf.close("s1").await.unwrap();
let cancel = tokio_util::sync::CancellationToken::new();
let sweep_handle = buf.clone().spawn_sweeper(cancel.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(matches!(
buf.read_meta("s1").await,
Err(ResumeError::NotFound(_))
));
cancel.cancel();
let _ = tokio::time::timeout(Duration::from_secs(1), sweep_handle).await;
}
}