use std::{
collections::{HashSet, VecDeque},
sync::Arc,
};
use axum::response::sse::Event as SseEvent;
use fraiseql_core::runtime::SubscriptionPlan;
use fraiseql_observers::{
listener::{ChangeLogReplayReader, ReplayScope, ResumePosition},
transport::TenantScope,
};
use futures::Stream;
use tokio::sync::broadcast::{Receiver, error::RecvError};
use super::sse::{StreamEvent, stream_event_matches, stream_lagged_payload};
use crate::subscriptions::EntityEvent as BridgeEvent;
const REPLAY_PAGE_SIZE: u32 = 500;
pub struct ResumeState {
pub reader: std::sync::Arc<ChangeLogReplayReader>,
pub scope: ReplayScope,
pub origin: ResumePosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Recorded,
InFlight,
Live,
}
struct Cursor {
plan: Arc<SubscriptionPlan>,
rx: Option<Receiver<BridgeEvent>>,
entity_type: String,
tenant: TenantScope,
resume: Option<ResumeState>,
read_at: Option<ResumePosition>,
pending: VecDeque<SseEvent>,
replayed: HashSet<i64>,
phase: Phase,
}
fn replay_failed_payload(reason: &str) -> serde_json::Value {
serde_json::json!({
"code": "REPLAY_FAILED",
"message": format!(
"This stream could not finish replaying what you missed ({reason}), so it \
is ending rather than continuing with events from now on and a gap you \
could not see. Reconnect to try again."
),
})
}
fn frame_for(event: &BridgeEvent, plan: &SubscriptionPlan, stream: &str) -> Option<SseEvent> {
let wire = StreamEvent::from_bridge_event(event);
let served = plan.deliver(wire.data)?;
let mut frame = SseEvent::default().event(wire.event_type);
let position = event.change_spine.as_ref().and_then(|envelope| envelope.seq);
if let Some(id) = position.and_then(|seq| super::stream_token::seal(seq, stream)) {
frame = frame.id(id);
}
frame.json_data(served).ok()
}
pub fn resumable_event_stream(
receiver: Receiver<BridgeEvent>,
entity_type: String,
tenant: TenantScope,
resume: Option<ResumeState>,
plan: Arc<SubscriptionPlan>,
) -> impl Stream<Item = SseEvent> {
let start = Cursor {
plan,
rx: Some(receiver),
entity_type,
tenant,
read_at: resume.as_ref().map(|r| r.origin),
phase: if resume.is_some() {
Phase::Recorded
} else {
Phase::Live
},
resume,
pending: VecDeque::new(),
replayed: HashSet::new(),
};
futures::stream::unfold(start, |mut state| async move {
loop {
if let Some(frame) = state.pending.pop_front() {
return Some((frame, state));
}
state.rx.as_ref()?;
match state.phase {
Phase::Recorded => {
let (Some(resume), Some(cursor)) = (state.resume.as_ref(), state.read_at)
else {
state.phase = Phase::Live;
continue;
};
match resume.reader.page(&resume.scope, &cursor, REPLAY_PAGE_SIZE).await {
Ok(events) if events.is_empty() => state.phase = Phase::InFlight,
Ok(events) => {
state.read_at = events.last().map(|e| e.position);
queue_replayed(&mut state, events);
},
Err(error) => return Some(end_with_replay_failure(state, &error)),
}
},
Phase::InFlight => {
let Some(resume) = state.resume.as_ref() else {
state.phase = Phase::Live;
continue;
};
let origin = resume.origin;
match resume
.reader
.page_in_flight(&resume.scope, &origin, REPLAY_PAGE_SIZE)
.await
{
Ok(events) => {
queue_replayed(&mut state, events);
state.phase = Phase::Live;
},
Err(error) => return Some(end_with_replay_failure(state, &error)),
}
},
Phase::Live => {
let rx = state.rx.as_mut()?;
match rx.recv().await {
Ok(event) => {
if !stream_event_matches(&event, &state.entity_type, &state.tenant) {
continue;
}
if event
.change_spine
.as_ref()
.and_then(|envelope| envelope.seq)
.is_some_and(|seq| state.replayed.contains(&seq))
{
continue;
}
let Some(frame) = frame_for(&event, &state.plan, &state.entity_type)
else {
continue;
};
return Some((frame, state));
},
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(
entity_type = %state.entity_type,
skipped,
"REST stream client lagged; ending the stream rather than \
resuming with a gap it cannot see"
);
let frame = SseEvent::default()
.event(super::sse::STREAM_LAGGED_EVENT)
.json_data(stream_lagged_payload(skipped))
.ok()?;
state.rx = None;
return Some((frame, state));
},
Err(RecvError::Closed) => return None,
}
},
}
}
})
}
fn queue_replayed(state: &mut Cursor, events: Vec<fraiseql_observers::listener::ReplayedEvent>) {
for replayed in events {
let Some(event) = crate::observers::runtime::bridge_event_for(&replayed.event) else {
continue;
};
if !stream_event_matches(&event, &state.entity_type, &state.tenant) {
continue;
}
if let Some(seq) = event.change_spine.as_ref().and_then(|envelope| envelope.seq) {
state.replayed.insert(seq);
}
if let Some(frame) = frame_for(&event, &state.plan, &state.entity_type) {
state.pending.push_back(frame);
}
}
}
fn end_with_replay_failure(
mut state: Cursor,
error: &fraiseql_observers::error::ObserverError,
) -> (SseEvent, Cursor) {
tracing::error!(
entity_type = %state.entity_type,
%error,
"REST stream could not complete its replay; ending the stream rather than \
continuing live with an unseen gap"
);
let frame = SseEvent::default()
.event(super::sse::STREAM_LAGGED_EVENT)
.json_data(replay_failed_payload(&error.to_string()))
.unwrap_or_else(|_| SseEvent::default().event(super::sse::STREAM_LAGGED_EVENT).data(""));
state.rx = None;
state.pending.clear();
(frame, state)
}