use futures::StreamExt;
use crate::Step;
use crate::catchup::Catchup;
pub const CATCHUP_CHUNK: usize = 1024;
struct LiveState<C: Catchup> {
c: C,
read_from: Option<C::Position>,
scan: Option<C::Scan>,
drained_in_chunk: usize,
caught_up: bool,
}
pub fn live_stepped<C: Catchup + 'static>(
c: C,
from: Option<C::Position>,
) -> impl futures::Stream<Item = Result<Step<C::Item>, C::Error>> + Send
where
C::Scan: Unpin,
{
let state = LiveState {
read_from: from,
c,
scan: None,
drained_in_chunk: 0,
caught_up: false,
};
futures::stream::unfold(state, |mut s| async move {
loop {
if s.scan.is_none() {
match s.c.read_after(s.read_from).await {
Ok(scan) => {
s.scan = Some(scan);
s.drained_in_chunk = 0;
}
Err(e) => return Some((Err(e), s)),
}
}
let Some(scan) = s.scan.as_mut() else {
continue;
};
match scan.next().await {
Some(Ok(item)) => {
s.read_from = Some(C::position_of(&item));
s.drained_in_chunk += 1;
if s.drained_in_chunk >= CATCHUP_CHUNK {
s.scan = None; }
return Some((Ok(Step::Event(item)), s));
}
Some(Err(e)) => {
s.scan = None;
return Some((Err(e), s));
}
None => {
s.scan = None;
let wait = s.c.arm();
match s.c.read_after(s.read_from).await {
Ok(mut probe) => match probe.next().await {
Some(Ok(item)) => {
s.read_from = Some(C::position_of(&item));
s.scan = Some(probe);
s.drained_in_chunk = 1;
return Some((Ok(Step::Event(item)), s));
}
Some(Err(e)) => return Some((Err(e), s)),
None => {
drop(probe);
if !s.caught_up {
s.caught_up = true;
#[cfg(feature = "tracing")]
tracing::info!(
name: "mnesis.subscription.caught_up",
position = ?s.read_from,
"subscription caught up"
);
return Some((Ok(Step::CaughtUp), s));
}
wait.await;
}
},
Err(e) => return Some((Err(e), s)),
}
}
}
}
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test code")]
#[allow(clippy::shadow_reuse, reason = "test code: env rebinds per loop turn")]
#[allow(
clippy::shadow_unrelated,
reason = "test code: env rebinds per loop turn"
)]
#[allow(clippy::doc_markdown, reason = "test code: prose doc comments")]
#[allow(
clippy::panic,
reason = "test code: unexpected Step variant is a test failure"
)]
mod tests {
use crate::envelope::PendingBatch;
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use mnesis::Version;
use tokio::time::timeout;
use super::*;
use crate::Step;
use crate::catchup::{AllCatchup, Catchup, StreamCatchup};
use crate::envelope::{PersistedEnvelope, pending_envelope};
use crate::store::RawEventStore;
use crate::stream_id::StreamKey;
use crate::test_support::TestStore;
const MUST_DELIVER: Duration = Duration::from_secs(5);
async fn seed_range(store: &TestStore, id: &StreamKey, lo: u64, hi: u64) {
for v in lo..=hi {
let env = pending_envelope(Version::new(v).unwrap())
.event_type("E")
.payload(b"e".to_vec())
.build()
.unwrap();
store
.append(id, Version::new(v - 1), PendingBatch::of(&env))
.await
.unwrap();
}
}
fn live<C: Catchup + 'static>(
c: C,
from: Option<C::Position>,
) -> impl futures::Stream<Item = Result<C::Item, C::Error>>
where
C::Scan: Unpin,
{
live_stepped(c, from).filter_map(|item| async move {
match item {
Ok(Step::Event(ev)) => Some(Ok(ev)),
Ok(Step::CaughtUp) => None,
Err(e) => Some(Err(e)),
}
})
}
#[tokio::test]
async fn catch_up_yields_backlog_in_order() {
let store = Arc::new(TestStore::new());
let id = StreamKey::from_slice(b"s");
seed_range(&store, &id, 1, 5).await;
let catchup = StreamCatchup::new(Arc::clone(&store), b"s").unwrap();
let versions: Vec<u64> = live(catchup, None)
.take(5)
.map(|r| r.unwrap().1.version().as_u64())
.collect()
.await;
assert_eq!(
versions,
vec![1, 2, 3, 4, 5],
"catch-up must deliver the backlog in order"
);
}
#[tokio::test]
async fn live_tail_sees_post_subscribe_append() {
let store = Arc::new(TestStore::new());
let id = StreamKey::from_slice(b"s");
seed_range(&store, &id, 1, 1).await;
let catchup = StreamCatchup::new(Arc::clone(&store), b"s").unwrap();
let cursor = live(catchup, None);
tokio::pin!(cursor);
let first = timeout(MUST_DELIVER, cursor.next())
.await
.expect("catch-up event must arrive")
.expect("stream never ends")
.unwrap();
assert_eq!(first.1.version().as_u64(), 1, "catch-up event is version 1");
let writer = Arc::clone(&store);
let appender = tokio::spawn(async move {
seed_range(&writer, &StreamKey::from_slice(b"s"), 2, 2).await;
});
let second = timeout(MUST_DELIVER, cursor.next())
.await
.expect("live append must wake the parked cursor")
.expect("stream never ends")
.unwrap();
assert_eq!(
second.1.version().as_u64(),
2,
"live tail must deliver the post-subscribe append"
);
appender.await.unwrap();
}
#[tokio::test]
async fn chunk_boundary_no_duplicate_no_gap() {
let total = u64::try_from(CATCHUP_CHUNK).unwrap() + 3;
let store = Arc::new(TestStore::new());
let id = StreamKey::from_slice(b"s");
seed_range(&store, &id, 1, total).await;
let catchup = StreamCatchup::new(Arc::clone(&store), b"s").unwrap();
let take_n = CATCHUP_CHUNK + 3;
let versions: Vec<u64> = live(catchup, None)
.take(take_n)
.map(|r| r.unwrap().1.version().as_u64())
.collect()
.await;
let expected: Vec<u64> = (1..=total).collect();
assert_eq!(
versions, expected,
"chunk-reopen must deliver 1..=total with no duplicate and no gap"
);
}
#[tokio::test]
async fn all_catchup_yields_global_order_then_live_append() {
let store = Arc::new(TestStore::new());
seed_range(&store, &StreamKey::from_slice(b"a"), 1, 1).await;
seed_range(&store, &StreamKey::from_slice(b"b"), 1, 1).await;
let env = pending_envelope(Version::new(2).unwrap())
.event_type("E")
.payload(b"e".to_vec())
.build()
.unwrap();
store
.append(
&StreamKey::from_slice(b"a"),
Version::new(1),
PendingBatch::of(&env),
)
.await
.unwrap();
let catchup = AllCatchup::new(Arc::clone(&store)).unwrap();
let cursor = live(catchup, None);
tokio::pin!(cursor);
let mut seqs = Vec::new();
for _ in 0..3 {
let (pos, _key, _env) = timeout(MUST_DELIVER, cursor.next())
.await
.expect("catch-up event must arrive")
.expect("stream never ends")
.unwrap();
seqs.push(pos.as_u64());
}
assert_eq!(
seqs,
vec![1, 2, 3],
"$all catch-up must deliver every stream's events in position order"
);
let writer = Arc::clone(&store);
let appender = tokio::spawn(async move {
let env = pending_envelope(Version::new(2).unwrap())
.event_type("E")
.payload(b"e".to_vec())
.build()
.unwrap();
writer
.append(
&StreamKey::from_slice(b"b"),
Version::new(1),
PendingBatch::of(&env),
)
.await
.unwrap();
});
let (live_pos, _live_key, _live_env) = timeout(MUST_DELIVER, cursor.next())
.await
.expect("live append must wake the parked $all cursor")
.expect("stream never ends")
.unwrap();
assert_eq!(
live_pos.as_u64(),
4,
"$all live tail must deliver the post-subscribe append at position 4"
);
appender.await.unwrap();
}
#[derive(Debug)]
struct BoomError;
impl core::fmt::Display for BoomError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("boom")
}
}
impl std::error::Error for BoomError {}
struct FailingCatchup {
ok_env: PersistedEnvelope,
}
impl Catchup for FailingCatchup {
type Position = Version;
type Item = (Version, PersistedEnvelope);
type Scan = futures::stream::Iter<
std::vec::IntoIter<Result<(Version, PersistedEnvelope), BoomError>>,
>;
type Error = BoomError;
fn read_after(
&self,
_from: Option<Version>,
) -> impl core::future::Future<Output = Result<Self::Scan, Self::Error>> + Send {
let scan = futures::stream::iter(vec![
Ok((Version::INITIAL, self.ok_env.clone())),
Err(BoomError),
]);
core::future::ready(Ok(scan))
}
fn position_of(item: &Self::Item) -> Version {
item.0
}
fn arm(&self) -> impl core::future::Future<Output = ()> + Send + 'static {
core::future::ready(())
}
}
#[tokio::test]
async fn scan_item_error_is_surfaced_in_order() {
let store = Arc::new(TestStore::new());
seed_range(&store, &StreamKey::from_slice(b"s"), 1, 1).await;
let (_pos, _key, ok_env) = store
.read_all(None)
.await
.unwrap()
.next()
.await
.expect("seeded event must be present")
.unwrap();
let cursor = live(FailingCatchup { ok_env }, None);
tokio::pin!(cursor);
let first = timeout(MUST_DELIVER, cursor.next())
.await
.expect("first item must arrive")
.expect("stream never ends");
assert!(first.is_ok(), "first item is the Ok event, got {first:?}");
let second = timeout(MUST_DELIVER, cursor.next())
.await
.expect("error item must arrive")
.expect("stream never ends");
assert!(
second.is_err(),
"scan error must be surfaced as Err, got {second:?}"
);
}
#[tokio::test]
async fn live_stepped_emits_caught_up_at_the_boundary() {
let store = Arc::new(TestStore::new());
let id = StreamKey::from_slice(b"s");
seed_range(&store, &id, 1, 2).await;
let catchup = StreamCatchup::new(Arc::clone(&store), b"s").unwrap();
let cursor = live_stepped(catchup, None);
tokio::pin!(cursor);
for expected in [1u64, 2] {
let step = timeout(MUST_DELIVER, cursor.next())
.await
.unwrap()
.unwrap()
.unwrap();
match step {
Step::Event((_pos, env)) => assert_eq!(env.version().as_u64(), expected),
Step::CaughtUp => panic!("caught up before draining the backlog"),
}
}
let marker = timeout(MUST_DELIVER, cursor.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert!(
marker.is_caught_up(),
"expected CaughtUp at the boundary, got {marker:?}"
);
let writer = Arc::clone(&store);
let appender = tokio::spawn(async move {
seed_range(&writer, &StreamKey::from_slice(b"s"), 3, 3).await;
});
let live = timeout(MUST_DELIVER, cursor.next())
.await
.unwrap()
.unwrap()
.unwrap();
match live {
Step::Event((_pos, env)) => assert_eq!(env.version().as_u64(), 3),
Step::CaughtUp => panic!("CaughtUp must be emitted only once"),
}
appender.await.unwrap();
}
}