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,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct StreamEntry {
pub stream_id: String,
pub is_terminal: bool,
pub next_id: u64,
pub last_write_unix_ms: u64,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct StreamPage {
pub entries: Vec<StreamEntry>,
pub next: Option<String>,
}
pub struct KvResumeBuffer {
kv: Arc<dyn KvStore>,
tuning: SweeperTuning,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct DirectorySummary {
is_terminal: bool,
next_id: u64,
last_write_unix_ms: u64,
}
impl DirectorySummary {
fn from_meta(meta: &BufferMeta) -> Self {
Self {
is_terminal: meta.terminal,
next_id: meta.next_id,
last_write_unix_ms: meta.last_write_unix_ms,
}
}
}
#[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 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?;
let summary_bytes = match serde_json::to_vec(&DirectorySummary::from_meta(&meta)) {
Ok(b) => Bytes::from(b),
Err(e) => {
tracing::error!(
target: "klieo.resume",
stream_id,
error = %e,
"directory summary serialization failed"
);
return Ok(());
}
};
if let Err(e) = self
.kv
.put(RESUME_DIRECTORY_BUCKET, stream_id, summary_bytes)
.await
{
tracing::warn!(
target: "klieo.resume",
stream_id,
error = %e,
"resume directory index write failed; sweeper may miss this stream"
);
}
Ok(())
}
#[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?;
let summary_bytes = match serde_json::to_vec(&DirectorySummary::from_meta(&meta)) {
Ok(b) => Bytes::from(b),
Err(e) => {
tracing::warn!(
target: "klieo.resume",
stream_id,
error = %e,
"close: directory summary serialization failed"
);
return Ok(());
}
};
if let Err(e) = self
.kv
.put(RESUME_DIRECTORY_BUCKET, stream_id, summary_bytes)
.await
{
tracing::warn!(
target: "klieo.resume",
stream_id,
error = %e,
"close: directory index update failed"
);
}
Ok(())
}
#[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 async fn list_all_streams(&self) -> Result<Vec<StreamEntry>, BusError> {
let pairs = match self.kv.scan_bucket(RESUME_DIRECTORY_BUCKET).await {
Ok(p) => p,
Err(BusError::Unsupported(_)) => return Ok(vec![]),
Err(e) => return Err(e),
};
let mut entries = Vec::with_capacity(pairs.len());
for (stream_id, value) in pairs {
if let Some(entry) = self.entry_from_directory(stream_id, &value).await {
entries.push(entry);
}
}
Ok(entries)
}
pub async fn list_streams_paged(
&self,
cursor: Option<String>,
limit: usize,
) -> Result<StreamPage, BusError> {
let page = match self
.kv
.keys_paginated(RESUME_DIRECTORY_BUCKET, cursor, limit)
.await
{
Ok(p) => p,
Err(BusError::Unsupported(_)) => {
return Ok(StreamPage {
entries: vec![],
next: None,
})
}
Err(e) => return Err(e),
};
let mut entries = Vec::with_capacity(page.keys.len());
for key in page.keys {
let Some(entry) = self.kv.get(RESUME_DIRECTORY_BUCKET, &key).await? else {
continue;
};
if let Some(built) = self.entry_from_directory(key, &entry.value).await {
entries.push(built);
}
}
Ok(StreamPage {
entries,
next: page.next,
})
}
async fn entry_from_directory(&self, stream_id: String, value: &[u8]) -> Option<StreamEntry> {
match serde_json::from_slice::<DirectorySummary>(value) {
Ok(summary) => Some(StreamEntry {
stream_id,
is_terminal: summary.is_terminal,
next_id: summary.next_id,
last_write_unix_ms: summary.last_write_unix_ms,
}),
Err(e) => {
tracing::debug!(
target: "klieo.resume",
stream_id = %stream_id,
error = %e,
"directory entry not a summary, falling back to read_meta"
);
match self.read_meta(&stream_id).await {
Ok(meta) => Some(StreamEntry {
stream_id,
is_terminal: meta.terminal,
next_id: meta.next_id,
last_write_unix_ms: meta.last_write_unix_ms,
}),
Err(ResumeError::NotFound(_)) => None,
Err(other) => {
tracing::warn!(
target: "klieo.resume",
stream_id,
error = %other,
"fallback read_meta failed; skipping stream"
);
None
}
}
}
}
}
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(_)) => {
if let Err(e) = self.kv.delete(RESUME_DIRECTORY_BUCKET, &stream_id).await {
tracing::warn!(
target: "klieo.resume.sweeper",
stream_id,
error = %e,
"stale directory-entry delete failed; will retry next interval"
);
}
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 {
if let Err(e) = self.kv.delete(RESUME_DIRECTORY_BUCKET, stream_id).await {
tracing::warn!(
target: "klieo.resume.sweeper",
stream_id,
error = %e,
"directory-entry purge failed; will retry next interval"
);
}
} 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;
}
#[tokio::test]
async fn list_all_streams_empty_when_no_streams() {
let buf = in_mem_buffer();
let entries = buf.list_all_streams().await.unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn list_all_streams_returns_active_and_terminal() {
use bytes::Bytes;
let buf = in_mem_buffer();
buf.record("stream-a", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
buf.record("stream-a", 2, Bytes::from_static(b"evt"))
.await
.unwrap();
buf.record("stream-b", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
buf.close("stream-b").await.unwrap();
let mut entries = buf.list_all_streams().await.unwrap();
entries.sort_by(|a, b| a.stream_id.cmp(&b.stream_id));
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].stream_id, "stream-a");
assert!(!entries[0].is_terminal);
assert_eq!(entries[0].next_id, 3);
assert_eq!(entries[1].stream_id, "stream-b");
assert!(entries[1].is_terminal);
assert_eq!(entries[1].next_id, 2);
}
#[tokio::test]
async fn list_all_streams_falls_back_to_read_meta_for_legacy_entries() {
use crate::test_utils::FakeKvStore;
use bytes::Bytes;
let kv = Arc::new(FakeKvStore::new());
let buf = KvResumeBuffer::new(kv.clone() as Arc<dyn crate::bus::KvStore>);
buf.record("stream-new", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
buf.record("stream-legacy", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
kv.put(
RESUME_DIRECTORY_BUCKET,
"stream-legacy",
Bytes::from_static(b"1"),
)
.await
.unwrap();
let mut entries = buf.list_all_streams().await.unwrap();
entries.sort_by(|a, b| a.stream_id.cmp(&b.stream_id));
assert_eq!(
entries.len(),
2,
"both streams must appear regardless of directory entry format"
);
assert!(
entries.iter().any(|e| e.stream_id == "stream-legacy"),
"stream-legacy must be included via read_meta fallback"
);
assert!(
entries.iter().any(|e| e.stream_id == "stream-new"),
"stream-new must be included via normal directory summary path"
);
}
#[tokio::test]
async fn list_streams_paged_bounds_and_cursors() {
use bytes::Bytes;
const SEED_COUNT: usize = 5;
const PAGE_LIMIT: usize = 2;
let buf = in_mem_buffer();
let mut seeded: Vec<String> = (0..SEED_COUNT).map(|i| format!("s{i}")).collect();
for id in &seeded {
buf.record(id, 1, Bytes::from_static(b"evt")).await.unwrap();
}
seeded.sort();
let page1 = buf.list_streams_paged(None, PAGE_LIMIT).await.unwrap();
assert_eq!(stream_ids(&page1), seeded[0..2]);
let cursor1 = page1.next.expect("first page must have a next cursor");
let page2 = buf
.list_streams_paged(Some(cursor1), PAGE_LIMIT)
.await
.unwrap();
assert_eq!(stream_ids(&page2), seeded[2..4]);
let cursor2 = page2.next.expect("second page must have a next cursor");
let page3 = buf
.list_streams_paged(Some(cursor2), PAGE_LIMIT)
.await
.unwrap();
assert_eq!(stream_ids(&page3), seeded[4..5]);
assert!(
page3.next.is_none(),
"final page must report an exhausted cursor"
);
}
#[tokio::test]
async fn list_streams_paged_skips_legacy_or_corrupt() {
use crate::test_utils::FakeKvStore;
use bytes::Bytes;
const PAGE_LIMIT: usize = 10;
let kv = Arc::new(FakeKvStore::new());
let buf = KvResumeBuffer::new(kv.clone() as Arc<dyn crate::bus::KvStore>);
buf.record("stream-new", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
buf.record("stream-legacy", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
kv.put(
RESUME_DIRECTORY_BUCKET,
"stream-legacy",
Bytes::from_static(b"1"),
)
.await
.unwrap();
kv.put(
RESUME_DIRECTORY_BUCKET,
"stream-orphan",
Bytes::from_static(b"1"),
)
.await
.unwrap();
let page = buf.list_streams_paged(None, PAGE_LIMIT).await.unwrap();
let ids = stream_ids(&page);
assert!(
ids.contains(&"stream-new".to_string()),
"summary entry must appear"
);
assert!(
ids.contains(&"stream-legacy".to_string()),
"legacy entry must appear via read_meta fallback"
);
assert!(
!ids.contains(&"stream-orphan".to_string()),
"orphan directory key without meta must be skipped"
);
}
#[tokio::test]
async fn list_streams_paged_empty_when_unsupported() {
use crate::test_utils::NoopKv;
let buf = KvResumeBuffer::new(Arc::new(NoopKv) as Arc<dyn crate::bus::KvStore>);
let page = buf.list_streams_paged(None, 10).await.unwrap();
assert!(page.entries.is_empty());
assert!(page.next.is_none());
}
struct DropsOneKeyOnGet {
inner: Arc<dyn KvStore>,
raced_key: String,
}
#[async_trait]
impl KvStore for DropsOneKeyOnGet {
async fn get(
&self,
bucket: &str,
key: &str,
) -> Result<Option<crate::bus::KvEntry>, BusError> {
if bucket == RESUME_DIRECTORY_BUCKET && key == self.raced_key {
return Ok(None);
}
self.inner.get(bucket, key).await
}
async fn put(
&self,
bucket: &str,
key: &str,
value: Bytes,
) -> Result<crate::bus::Revision, BusError> {
self.inner.put(bucket, key, value).await
}
async fn cas(
&self,
bucket: &str,
key: &str,
value: Bytes,
expected: Option<crate::bus::Revision>,
) -> Result<crate::bus::Revision, BusError> {
self.inner.cas(bucket, key, value, expected).await
}
async fn delete(&self, bucket: &str, key: &str) -> Result<(), BusError> {
self.inner.delete(bucket, key).await
}
async fn lease(
&self,
bucket: &str,
key: &str,
ttl: Duration,
) -> Result<crate::bus::Lease, BusError> {
self.inner.lease(bucket, key, ttl).await
}
async fn keys(&self, bucket: &str) -> Result<Vec<String>, BusError> {
self.inner.keys(bucket).await
}
}
#[tokio::test]
async fn list_streams_paged_skips_raced_deletion() {
use crate::test_utils::FakeKvStore;
const PAGE_LIMIT: usize = 10;
const RACED: &str = "stream-raced";
let backing = Arc::new(FakeKvStore::new());
let seed = KvResumeBuffer::new(backing.clone() as Arc<dyn KvStore>);
seed.record("stream-live", 1, Bytes::from_static(b"evt"))
.await
.unwrap();
seed.record(RACED, 1, Bytes::from_static(b"evt"))
.await
.unwrap();
let raced_kv = Arc::new(DropsOneKeyOnGet {
inner: backing as Arc<dyn KvStore>,
raced_key: RACED.to_string(),
});
let buf = KvResumeBuffer::new(raced_kv as Arc<dyn KvStore>);
let page = buf.list_streams_paged(None, PAGE_LIMIT).await.unwrap();
let ids = stream_ids(&page);
assert!(
ids.contains(&"stream-live".to_string()),
"surviving stream must still be returned"
);
assert!(
!ids.contains(&RACED.to_string()),
"raced-deleted stream must be silently skipped, not errored"
);
}
fn stream_ids(page: &StreamPage) -> Vec<String> {
page.entries.iter().map(|e| e.stream_id.clone()).collect()
}
}