1use aion::EventStreamLagged;
22use aion_core::Event;
23use aion_proto::{WireError, WireErrorCode};
24use futures::StreamExt;
25use futures::stream::BoxStream;
26
27use crate::error::ServerError;
28
29pub const RESUME_CURSOR_AHEAD_OF_HISTORY: &str = "ResumeCursorAheadOfHistory";
31
32pub type LiveEventStream = BoxStream<'static, Result<Event, EventStreamLagged>>;
34
35pub 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 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 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 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}