Skip to main content

aion_server/stream/
resume.rs

1//! Replay/live splice for per-workflow subscription resumption.
2//!
3//! A resume cursor (`resume_from_seq` = R, "first seq wanted") is honored by
4//! replaying the recorded history slice `[R ..= head]` and then tailing the
5//! live broadcast filtered to `seq > head`:
6//!
7//! - **Gap-free**: the caller attaches the live subscription *before* taking
8//!   the history snapshot, and the engine's `PublishingEventStore` broadcasts
9//!   strictly after durable commit, so every event with `seq > head` was
10//!   committed — and therefore broadcast — after the live receiver attached.
11//! - **Duplicate-free**: the replay slice delivers exactly `[R ..= head]` from
12//!   the snapshot, and the live filter drops every `seq <= head`, so an event
13//!   that both landed in the snapshot and arrived on the broadcast is emitted
14//!   exactly once, from the snapshot.
15//!
16//! Anti-leak contract: callers run the namespace guard verdict before reading
17//! history or validating the cursor, so an unauthorized probe always receives
18//! the guard's `not_found` and never a cursor error that would disclose a
19//! foreign workflow's existence or history length.
20
21use aion::EventStreamLagged;
22use aion_core::Event;
23use aion_proto::{WireError, WireErrorCode};
24use futures::StreamExt;
25use futures::stream::BoxStream;
26
27use crate::error::ServerError;
28
29/// `error_type` discriminator for a cursor beyond the recorded history head.
30pub const RESUME_CURSOR_AHEAD_OF_HISTORY: &str = "ResumeCursorAheadOfHistory";
31
32/// Live event stream item type shared with the engine subscription seam.
33pub type LiveEventStream = BoxStream<'static, Result<Event, EventStreamLagged>>;
34
35/// Validate a resume cursor against a history snapshot and build the splice.
36///
37/// `live` must be attached before `history` was read (subscribe-then-snapshot)
38/// and `history` must be the full per-workflow history sorted by `seq` — both
39/// halves of the gap-free argument documented on this module.
40///
41/// Returns the replay slice (`seq >= resume_from_seq`) and the live tail
42/// filtered to `seq > head`; lag items pass through unfiltered so a lagging
43/// consumer is always told, never silently gapped.
44///
45/// # Errors
46///
47/// Returns [`ServerError::Wire`] `invalid_input` when `resume_from_seq` is `0`,
48/// or `invalid_input` with `error_type` [`RESUME_CURSOR_AHEAD_OF_HISTORY`] when
49/// `resume_from_seq > head + 1`.
50pub fn splice(
51    live: LiveEventStream,
52    history: Vec<Event>,
53    resume_from_seq: u64,
54) -> Result<(Vec<Event>, LiveEventStream), ServerError> {
55    if resume_from_seq == 0 {
56        return Err(WireError::invalid_input("resume_from_seq must be >= 1").into());
57    }
58    let head = history.last().map_or(0, Event::seq);
59    if resume_from_seq > head.saturating_add(1) {
60        return Err(WireError::new_with_type(
61            WireErrorCode::InvalidInput,
62            RESUME_CURSOR_AHEAD_OF_HISTORY,
63            format!(
64                "resume_from_seq {resume_from_seq} is ahead of recorded history \
65                 (head seq {head}); the largest valid cursor is {}",
66                head.saturating_add(1)
67            ),
68        )
69        .into());
70    }
71
72    let mut history = history;
73    let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
74    let replay = history.split_off(replay_start);
75
76    let tail = live
77        .filter(move |item| {
78            let keep = match item {
79                Ok(event) => event.seq() > head,
80                // Lag is information, never filtered away.
81                Err(EventStreamLagged { .. }) => true,
82            };
83            futures::future::ready(keep)
84        })
85        .boxed();
86
87    Ok((replay, tail))
88}
89
90#[cfg(test)]
91mod tests {
92    use std::time::Duration;
93
94    use aion::EventStreamLagged;
95    use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
96    use aion_proto::WireErrorCode;
97    use futures::{StreamExt, stream};
98
99    use super::{RESUME_CURSOR_AHEAD_OF_HISTORY, splice};
100    use crate::namespace::{NamespaceResolver, StaticWorkflowNamespaces};
101    use crate::stream::namespace_filter::NamespaceEventGate;
102    use crate::stream::socket::spawn_encoded_event_stream;
103
104    fn workflow_id() -> WorkflowId {
105        WorkflowId::new(uuid::Uuid::from_u128(1))
106    }
107
108    fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
109        Ok(Event::SignalReceived {
110            envelope: EventEnvelope {
111                seq,
112                recorded_at: chrono::Utc::now(),
113                workflow_id: workflow_id(),
114            },
115            name: format!("signal-{seq}"),
116            payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
117        })
118    }
119
120    fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
121        Ok(Event::WorkflowCompleted {
122            envelope: EventEnvelope {
123                seq,
124                recorded_at: chrono::Utc::now(),
125                workflow_id: workflow_id(),
126            },
127            result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
128        })
129    }
130
131    fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
132        seqs.map(signal).collect()
133    }
134
135    fn live(
136        items: Vec<Result<Event, EventStreamLagged>>,
137    ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
138        stream::iter(items).boxed()
139    }
140
141    fn gate() -> Result<NamespaceEventGate, Box<dyn std::error::Error>> {
142        let ownership = StaticWorkflowNamespaces::default();
143        ownership.record(workflow_id(), "tenant-a")?;
144        let resolver = NamespaceResolver::authorization_only(
145            crate::config::NamespaceMode::SharedEngine,
146            ownership,
147            crate::namespace::StaticScheduleNamespaces::default(),
148        );
149        let capacity = std::num::NonZeroUsize::new(8).ok_or("verdict capacity must be non-zero")?;
150        Ok(NamespaceEventGate::new(
151            resolver,
152            "tenant-a".to_owned(),
153            capacity,
154        ))
155    }
156
157    fn delivered_seqs(events: &[Event]) -> Vec<u64> {
158        events.iter().map(Event::seq).collect()
159    }
160
161    #[tokio::test]
162    async fn cursor_zero_is_invalid_input() -> Result<(), Box<dyn std::error::Error>> {
163        let error = splice(live(Vec::new()), history(1..=3)?, 0)
164            .err()
165            .map(|error| error.to_wire_error())
166            .ok_or("cursor 0 must be rejected")?;
167
168        assert_eq!(error.code, WireErrorCode::InvalidInput);
169        assert!(error.message.contains("resume_from_seq must be >= 1"));
170        Ok(())
171    }
172
173    #[tokio::test]
174    async fn cursor_ahead_of_history_is_invalid_input_with_discriminator()
175    -> Result<(), Box<dyn std::error::Error>> {
176        let error = splice(live(Vec::new()), history(1..=5)?, 7)
177            .err()
178            .map(|error| error.to_wire_error())
179            .ok_or("cursor head+2 must be rejected")?;
180
181        assert_eq!(error.code, WireErrorCode::InvalidInput);
182        assert_eq!(
183            error.error_type.as_deref(),
184            Some(RESUME_CURSOR_AHEAD_OF_HISTORY)
185        );
186        Ok(())
187    }
188
189    #[tokio::test]
190    async fn cursor_ahead_of_empty_history_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
191        let error = splice(live(Vec::new()), Vec::new(), 2)
192            .err()
193            .map(|error| error.to_wire_error())
194            .ok_or("cursor 2 over empty history must be rejected")?;
195
196        assert_eq!(
197            error.error_type.as_deref(),
198            Some(RESUME_CURSOR_AHEAD_OF_HISTORY)
199        );
200        Ok(())
201    }
202
203    #[tokio::test]
204    async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
205    -> Result<(), Box<dyn std::error::Error>> {
206        let (replay, tail) = splice(
207            live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
208            history(1..=5)?,
209            6,
210        )?;
211
212        assert!(replay.is_empty(), "head+1 cursor must replay nothing");
213        let tail: Vec<u64> = tail
214            .map(|item| item.map(|event| event.seq()).unwrap_or_default())
215            .collect()
216            .await;
217        assert_eq!(tail, vec![6, 7]);
218        Ok(())
219    }
220
221    #[tokio::test]
222    async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
223    -> Result<(), Box<dyn std::error::Error>> {
224        // Snapshot holds 1..=5; the live broadcast re-emits 4 and 5 (arrived
225        // between attach and snapshot) before the genuinely new 6.
226        let (replay, tail) = splice(
227            live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
228            history(1..=5)?,
229            1,
230        )?;
231
232        let mut delivered = delivered_seqs(&replay);
233        let tail: Vec<u64> = tail
234            .map(|item| item.map(|event| event.seq()).unwrap_or_default())
235            .collect()
236            .await;
237        delivered.extend(tail);
238        assert_eq!(
239            delivered,
240            vec![1, 2, 3, 4, 5, 6],
241            "delivery must be contiguous and duplicate-free"
242        );
243        Ok(())
244    }
245
246    #[tokio::test]
247    async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
248        let (replay, _tail) = splice(live(Vec::new()), history(1..=5)?, 3)?;
249
250        assert_eq!(delivered_seqs(&replay), vec![3, 4, 5]);
251        Ok(())
252    }
253
254    #[tokio::test]
255    async fn replay_containing_terminal_event_closes_after_it()
256    -> Result<(), Box<dyn std::error::Error>> {
257        // Terminal at seq 3 mid-replay: the socket must deliver 1..=3 and then
258        // close without draining the live tail (CAN/terminal run boundary).
259        let mut history = history(1..=2)?;
260        history.push(completed(3)?);
261        history.push(signal(4)?);
262        let (replay, tail) = splice(live(vec![Ok(signal(5)?)]), history, 1)?;
263
264        let subscription = crate::stream::EventSubscription {
265            namespace: "tenant-a".to_owned(),
266            filter: aion::EventFilter::default(),
267            selector: crate::stream::selector::SubscriptionSelector::unrestricted(),
268            workflow_target: Some(workflow_id()),
269            replay,
270            events: tail,
271        };
272        let mut encoded = spawn_encoded_event_stream(subscription, gate()?, 8)?;
273
274        let mut frames = 0_usize;
275        while let Some(frame) =
276            tokio::time::timeout(Duration::from_secs(1), encoded.frames.recv()).await?
277        {
278            drop(frame);
279            frames += 1;
280        }
281        assert_eq!(
282            frames, 3,
283            "stream must close after the terminal replay frame"
284        );
285        Ok(())
286    }
287
288    #[tokio::test]
289    async fn lag_mid_splice_surfaces_terminal_lagged_error()
290    -> Result<(), Box<dyn std::error::Error>> {
291        let (replay, tail) = splice(
292            live(vec![Err(EventStreamLagged { skipped: 3 })]),
293            history(1..=2)?,
294            1,
295        )?;
296
297        let subscription = crate::stream::EventSubscription {
298            namespace: "tenant-a".to_owned(),
299            filter: aion::EventFilter::default(),
300            selector: crate::stream::selector::SubscriptionSelector::unrestricted(),
301            workflow_target: Some(workflow_id()),
302            replay,
303            events: tail,
304        };
305        let mut encoded = spawn_encoded_event_stream(subscription, gate()?, 8)?;
306
307        let mut frames = 0_usize;
308        while let Some(frame) =
309            tokio::time::timeout(Duration::from_secs(1), encoded.frames.recv()).await?
310        {
311            drop(frame);
312            frames += 1;
313        }
314        assert_eq!(frames, 2, "both replay frames must be delivered before lag");
315        let lag = tokio::time::timeout(Duration::from_secs(1), encoded.lagged).await??;
316        assert_eq!(lag.code, WireErrorCode::Lagged);
317        Ok(())
318    }
319}