1use std::collections::{HashMap, VecDeque};
2use std::fmt;
3use std::pin::Pin;
4use std::sync::{Arc, Mutex as StdMutex};
5use std::task::{Context, Poll, ready};
6use std::time::{Duration, Instant};
7
8use futures_util::Stream;
9use tokio::sync::broadcast;
10use tokio_util::sync::ReusableBoxFuture;
11
12use crate::runtime::LashRuntime;
13#[cfg(test)]
14use crate::runtime::RuntimeSessionState;
15
16const SESSION_CURSOR_PREFIX: &str = "lashsc1:";
17const DEFAULT_LIVE_REPLAY_CAPACITY: usize = 2048;
18const DEFAULT_LIVE_REPLAY_TTL: Duration = Duration::from_secs(120);
19
20#[derive(
21 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
22)]
23#[serde(transparent)]
24pub struct SessionRevision(pub u64);
25
26impl SessionRevision {
27 pub fn new(revision: u64) -> Self {
28 Self(revision)
29 }
30
31 pub fn as_u64(self) -> u64 {
32 self.0
33 }
34
35 pub(super) fn from_runtime(runtime: &LashRuntime) -> Self {
36 Self(
37 runtime
38 .state
39 .head_revision
40 .unwrap_or(runtime.state.turn_index as u64),
41 )
42 }
43
44 #[cfg(test)]
45 pub(super) fn from_state(state: &RuntimeSessionState) -> Self {
46 Self(state.head_revision.unwrap_or(state.turn_index as u64))
47 }
48}
49
50#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
51#[serde(transparent)]
52pub struct SessionCursor(String);
53
54impl SessionCursor {
55 pub(crate) fn new(
56 session_id: impl AsRef<str>,
57 revision: SessionRevision,
58 live_position: u64,
59 ) -> Self {
60 Self(format!(
61 "{SESSION_CURSOR_PREFIX}{}:{live_position}:{}",
62 revision.0,
63 session_id.as_ref()
64 ))
65 }
66
67 #[cfg(test)]
68 pub(super) fn from_raw_for_testing(raw: impl Into<String>) -> Self {
69 Self(raw.into())
70 }
71
72 pub fn as_str(&self) -> &str {
73 &self.0
74 }
75
76 pub(crate) fn parse_for_session(
77 &self,
78 expected_session_id: &str,
79 ) -> Result<ParsedSessionCursor, SessionCursorError> {
80 let parsed = self.parse()?;
81 if parsed.session_id != expected_session_id {
82 return Err(SessionCursorError::WrongSession {
83 expected_session_id: expected_session_id.to_string(),
84 actual_session_id: parsed.session_id,
85 });
86 }
87 Ok(parsed)
88 }
89
90 fn parse(&self) -> Result<ParsedSessionCursor, SessionCursorError> {
91 let payload = self.0.strip_prefix(SESSION_CURSOR_PREFIX).ok_or_else(|| {
92 SessionCursorError::Malformed {
93 message: "missing cursor prefix".to_string(),
94 }
95 })?;
96 let mut parts = payload.splitn(3, ':');
97 let revision = parts
98 .next()
99 .ok_or_else(|| SessionCursorError::Malformed {
100 message: "missing session revision".to_string(),
101 })?
102 .parse::<u64>()
103 .map_err(|err| SessionCursorError::Malformed {
104 message: format!("invalid session revision: {err}"),
105 })?;
106 let live_position = parts
107 .next()
108 .ok_or_else(|| SessionCursorError::Malformed {
109 message: "missing live replay position".to_string(),
110 })?
111 .parse::<u64>()
112 .map_err(|err| SessionCursorError::Malformed {
113 message: format!("invalid live replay position: {err}"),
114 })?;
115 let session_id = parts
116 .next()
117 .filter(|value| !value.is_empty())
118 .ok_or_else(|| SessionCursorError::Malformed {
119 message: "missing session id".to_string(),
120 })?
121 .to_string();
122 Ok(ParsedSessionCursor {
123 session_id,
124 revision: SessionRevision(revision),
125 live_position,
126 })
127 }
128}
129
130impl fmt::Debug for SessionCursor {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str("SessionCursor(<opaque>)")
133 }
134}
135
136impl fmt::Display for SessionCursor {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(&self.0)
139 }
140}
141
142#[derive(Clone, Debug)]
143pub(crate) struct ParsedSessionCursor {
144 pub session_id: String,
145 pub revision: SessionRevision,
146 pub live_position: u64,
147}
148
149#[derive(Clone, Debug, thiserror::Error)]
150pub enum SessionCursorError {
151 #[error("malformed session cursor: {message}")]
152 Malformed { message: String },
153 #[error("session cursor belongs to `{actual_session_id}`, not `{expected_session_id}`")]
154 WrongSession {
155 expected_session_id: String,
156 actual_session_id: String,
157 },
158}
159
160#[derive(Clone, Debug)]
161pub struct SessionObservation {
162 pub read_view: crate::SessionReadView,
163 pub cursor: SessionCursor,
164}
165
166#[derive(Clone, Debug)]
167pub struct SessionObservationEvent {
168 pub session_id: String,
169 pub revision: SessionRevision,
170 pub cursor: SessionCursor,
171 pub payload: SessionObservationEventPayload,
172}
173
174#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum SessionQueueEventKind {
177 Enqueued,
178 Cancelled,
179}
180
181#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum SessionProcessEventKind {
184 Started,
185 Cancelled,
186}
187
188#[derive(Clone, Debug)]
189#[allow(clippy::large_enum_variant)]
190pub enum SessionObservationEventPayload {
191 TurnActivity(crate::TurnActivity),
192 Committed {
193 read_view: crate::SessionReadView,
194 },
195 AgentFrameSwitched {
196 frame_id: String,
197 },
198 QueueChanged {
199 kind: SessionQueueEventKind,
200 batch_ids: Vec<String>,
201 },
202 ProcessChanged {
203 kind: SessionProcessEventKind,
204 process_ids: Vec<String>,
205 },
206}
207
208#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
209pub struct LiveReplayGap {
210 pub session_id: String,
211 pub requested_cursor: SessionCursor,
212 pub latest_cursor: SessionCursor,
213 pub latest_revision: SessionRevision,
214 pub reason: LiveReplayGapReason,
215}
216
217#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum LiveReplayGapReason {
220 Trimmed,
221 Unavailable,
222}
223
224#[derive(Clone, Debug, thiserror::Error)]
225pub enum LiveReplayStoreError {
226 #[error("{0}")]
227 Cursor(#[from] SessionCursorError),
228 #[error("live replay store error: {0}")]
229 Store(String),
230 #[error("live replay subscriber lagged by {0} events")]
231 SubscriberLagged(u64),
232 #[error("live replay channel closed")]
233 Closed,
234}
235
236#[derive(Clone, Debug)]
237pub enum LiveReplayResult {
238 Replayed(Vec<Arc<SessionObservationEvent>>),
239 Gap(LiveReplayGapReason),
240}
241
242pub enum LiveReplaySubscribeResult {
243 Subscribed(LiveReplaySubscription),
244 Gap(LiveReplayGapReason),
245}
246
247type LiveReplayRecvResult = (
248 Result<Arc<SessionObservationEvent>, broadcast::error::RecvError>,
249 broadcast::Receiver<Arc<SessionObservationEvent>>,
250);
251
252#[cfg(test)]
253static LIVE_REPLAY_EVENT_CLONES: std::sync::atomic::AtomicUsize =
254 std::sync::atomic::AtomicUsize::new(0);
255
256#[inline]
257fn clone_event(event: &Arc<SessionObservationEvent>) -> Arc<SessionObservationEvent> {
258 #[cfg(test)]
259 LIVE_REPLAY_EVENT_CLONES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
260 Arc::clone(event)
261}
262
263pub struct LiveReplaySubscription {
264 replay: VecDeque<Arc<SessionObservationEvent>>,
265 receiver: ReusableBoxFuture<'static, LiveReplayRecvResult>,
266 closed: bool,
267}
268
269impl LiveReplaySubscription {
270 fn new(
271 replay: Vec<Arc<SessionObservationEvent>>,
272 receiver: broadcast::Receiver<Arc<SessionObservationEvent>>,
273 ) -> Self {
274 Self {
275 replay: replay.into(),
276 receiver: ReusableBoxFuture::new(live_replay_recv(receiver)),
277 closed: false,
278 }
279 }
280
281 pub async fn next_event(
282 &mut self,
283 ) -> Result<Arc<SessionObservationEvent>, LiveReplayStoreError> {
284 futures_util::StreamExt::next(self)
285 .await
286 .unwrap_or(Err(LiveReplayStoreError::Closed))
287 }
288}
289
290async fn live_replay_recv(
291 mut receiver: broadcast::Receiver<Arc<SessionObservationEvent>>,
292) -> LiveReplayRecvResult {
293 let result = receiver.recv().await;
294 #[cfg(test)]
295 if result.is_ok() {
296 LIVE_REPLAY_EVENT_CLONES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
297 }
298 (result, receiver)
299}
300
301impl Stream for LiveReplaySubscription {
302 type Item = Result<Arc<SessionObservationEvent>, LiveReplayStoreError>;
303
304 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
305 if let Some(event) = self.replay.pop_front() {
306 return Poll::Ready(Some(Ok(event)));
307 }
308 if self.closed {
309 return Poll::Ready(None);
310 }
311 let (result, receiver) = ready!(self.receiver.poll(cx));
312 self.receiver.set(live_replay_recv(receiver));
313 match result {
314 Ok(event) => Poll::Ready(Some(Ok(event))),
315 Err(broadcast::error::RecvError::Lagged(count)) => {
316 Poll::Ready(Some(Err(LiveReplayStoreError::SubscriberLagged(count))))
317 }
318 Err(broadcast::error::RecvError::Closed) => {
319 self.closed = true;
320 Poll::Ready(Some(Err(LiveReplayStoreError::Closed)))
321 }
322 }
323 }
324}
325
326#[derive(Clone, Debug)]
327pub enum SessionResume {
328 Replayed {
329 events: Vec<Arc<SessionObservationEvent>>,
330 },
331 Gap {
332 observation: SessionObservation,
333 gap: LiveReplayGap,
334 },
335}
336
337pub enum SessionObservationSubscription {
338 Subscribed(LiveReplaySubscription),
339 Gap {
340 observation: SessionObservation,
341 gap: LiveReplayGap,
342 },
343}
344
345pub trait LiveReplayStore: Send + Sync {
353 fn append(
357 &self,
358 session_id: &str,
359 revision: SessionRevision,
360 payload: SessionObservationEventPayload,
361 ) -> Result<Arc<SessionObservationEvent>, LiveReplayStoreError>;
362
363 fn replay_after_cursor(
367 &self,
368 cursor: &SessionCursor,
369 ) -> Result<LiveReplayResult, LiveReplayStoreError>;
370
371 fn subscribe_after_cursor(
375 &self,
376 cursor: &SessionCursor,
377 ) -> Result<LiveReplaySubscribeResult, LiveReplayStoreError>;
378
379 fn current_cursor(&self, session_id: &str, revision: SessionRevision) -> SessionCursor;
383
384 fn trim_session(&self, session_id: &str) -> Result<(), LiveReplayStoreError>;
388}
389
390#[derive(Clone, Debug)]
391pub struct InMemoryLiveReplayStoreConfig {
392 pub max_events_per_session: usize,
393 pub max_age: Duration,
394}
395
396impl Default for InMemoryLiveReplayStoreConfig {
397 fn default() -> Self {
398 Self {
399 max_events_per_session: DEFAULT_LIVE_REPLAY_CAPACITY,
400 max_age: DEFAULT_LIVE_REPLAY_TTL,
401 }
402 }
403}
404
405#[derive(Debug)]
406pub struct InMemoryLiveReplayStore {
407 config: InMemoryLiveReplayStoreConfig,
408 clock: Arc<dyn crate::Clock>,
409 sessions: StdMutex<HashMap<String, LiveReplaySessionBuffer>>,
410}
411
412impl InMemoryLiveReplayStore {
413 pub fn new(config: InMemoryLiveReplayStoreConfig) -> Self {
414 Self::with_clock(config, Arc::new(crate::SystemClock))
415 }
416
417 pub fn with_clock(config: InMemoryLiveReplayStoreConfig, clock: Arc<dyn crate::Clock>) -> Self {
418 Self {
419 config,
420 clock,
421 sessions: StdMutex::new(HashMap::new()),
422 }
423 }
424
425 pub fn with_bounds(max_events_per_session: usize, max_age: Duration) -> Self {
426 Self::new(InMemoryLiveReplayStoreConfig {
427 max_events_per_session,
428 max_age,
429 })
430 }
431}
432
433impl Default for InMemoryLiveReplayStore {
434 fn default() -> Self {
435 Self::new(InMemoryLiveReplayStoreConfig::default())
436 }
437}
438
439#[derive(Debug)]
440struct LiveReplaySessionBuffer {
441 events: VecDeque<StoredObservationEvent>,
442 tail_position: u64,
443 sender: Option<broadcast::Sender<Arc<SessionObservationEvent>>>,
444}
445
446impl LiveReplaySessionBuffer {
447 fn new() -> Self {
448 Self {
449 events: VecDeque::new(),
450 tail_position: 0,
451 sender: None,
452 }
453 }
454
455 fn subscribe(
456 &mut self,
457 channel_capacity: usize,
458 ) -> broadcast::Receiver<Arc<SessionObservationEvent>> {
459 match self.sender.as_ref() {
460 Some(sender) => sender.subscribe(),
461 None => {
462 let (sender, receiver) = broadcast::channel(channel_capacity.max(1));
463 self.sender = Some(sender);
464 receiver
465 }
466 }
467 }
468
469 fn publish(&mut self, event: Arc<SessionObservationEvent>) {
470 let Some(sender) = self.sender.as_ref() else {
471 return;
472 };
473 if sender.send(event).is_err() {
474 self.sender = None;
475 }
476 }
477}
478
479#[derive(Clone, Debug)]
480struct StoredObservationEvent {
481 position: u64,
482 appended_at: Instant,
483 event: Arc<SessionObservationEvent>,
484}
485
486impl InMemoryLiveReplayStore {
487 fn trim_locked(
488 config: &InMemoryLiveReplayStoreConfig,
489 buffer: &mut LiveReplaySessionBuffer,
490 now: Instant,
491 ) {
492 while buffer.events.len() > config.max_events_per_session {
493 buffer.events.pop_front();
494 }
495 while buffer
496 .events
497 .front()
498 .is_some_and(|event| now.duration_since(event.appended_at) > config.max_age)
499 {
500 buffer.events.pop_front();
501 }
502 }
503
504 fn gap_reason_for_cursor(
505 buffer: Option<&LiveReplaySessionBuffer>,
506 cursor_position: u64,
507 ) -> Option<LiveReplayGapReason> {
508 let Some(buffer) = buffer else {
509 return (cursor_position > 0).then_some(LiveReplayGapReason::Unavailable);
510 };
511 if cursor_position > buffer.tail_position {
512 return Some(LiveReplayGapReason::Unavailable);
513 }
514 let Some(first) = buffer.events.front() else {
515 return (cursor_position < buffer.tail_position)
516 .then_some(LiveReplayGapReason::Trimmed);
517 };
518 if cursor_position + 1 < first.position {
519 Some(LiveReplayGapReason::Trimmed)
520 } else {
521 None
522 }
523 }
524}
525
526impl LiveReplayStore for InMemoryLiveReplayStore {
527 fn append(
528 &self,
529 session_id: &str,
530 revision: SessionRevision,
531 payload: SessionObservationEventPayload,
532 ) -> Result<Arc<SessionObservationEvent>, LiveReplayStoreError> {
533 let now = self.clock.now();
534 let mut sessions = self
535 .sessions
536 .lock()
537 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
538 let buffer = sessions
539 .entry(session_id.to_string())
540 .or_insert_with(LiveReplaySessionBuffer::new);
541 buffer.tail_position = buffer.tail_position.saturating_add(1);
542 let cursor = SessionCursor::new(session_id, revision, buffer.tail_position);
543 let event = Arc::new(SessionObservationEvent {
544 session_id: session_id.to_string(),
545 revision,
546 cursor,
547 payload,
548 });
549 buffer.events.push_back(StoredObservationEvent {
550 position: buffer.tail_position,
551 appended_at: now,
552 event: clone_event(&event),
553 });
554 Self::trim_locked(&self.config, buffer, now);
555 buffer.publish(clone_event(&event));
556 Ok(event)
557 }
558
559 fn replay_after_cursor(
560 &self,
561 cursor: &SessionCursor,
562 ) -> Result<LiveReplayResult, LiveReplayStoreError> {
563 let parsed = cursor.parse()?;
564 let _cursor_revision = parsed.revision;
565 let now = self.clock.now();
566 let mut sessions = self
567 .sessions
568 .lock()
569 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
570 if let Some(buffer) = sessions.get_mut(&parsed.session_id) {
571 Self::trim_locked(&self.config, buffer, now);
572 }
573 let buffer = sessions.get(&parsed.session_id);
574 if let Some(reason) = Self::gap_reason_for_cursor(buffer, parsed.live_position) {
575 return Ok(LiveReplayResult::Gap(reason));
576 }
577 let events = buffer
578 .map(|buffer| {
579 buffer
580 .events
581 .iter()
582 .filter(|event| event.position > parsed.live_position)
583 .map(|event| clone_event(&event.event))
584 .collect()
585 })
586 .unwrap_or_default();
587 Ok(LiveReplayResult::Replayed(events))
588 }
589
590 fn subscribe_after_cursor(
591 &self,
592 cursor: &SessionCursor,
593 ) -> Result<LiveReplaySubscribeResult, LiveReplayStoreError> {
594 let parsed = cursor.parse()?;
595 let _cursor_revision = parsed.revision;
596 let now = self.clock.now();
597 let mut sessions = self
598 .sessions
599 .lock()
600 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
601 let buffer = sessions
602 .entry(parsed.session_id.clone())
603 .or_insert_with(LiveReplaySessionBuffer::new);
604 Self::trim_locked(&self.config, buffer, now);
605 if let Some(reason) = Self::gap_reason_for_cursor(Some(buffer), parsed.live_position) {
606 return Ok(LiveReplaySubscribeResult::Gap(reason));
607 }
608 let replay = buffer
609 .events
610 .iter()
611 .filter(|event| event.position > parsed.live_position)
612 .map(|event| clone_event(&event.event))
613 .collect();
614 let receiver = buffer.subscribe(self.config.max_events_per_session);
615 Ok(LiveReplaySubscribeResult::Subscribed(
616 LiveReplaySubscription::new(replay, receiver),
617 ))
618 }
619
620 fn current_cursor(&self, session_id: &str, revision: SessionRevision) -> SessionCursor {
621 let tail_position = self
622 .sessions
623 .lock()
624 .ok()
625 .and_then(|sessions| sessions.get(session_id).map(|buffer| buffer.tail_position))
626 .unwrap_or(0);
627 SessionCursor::new(session_id, revision, tail_position)
628 }
629
630 fn trim_session(&self, session_id: &str) -> Result<(), LiveReplayStoreError> {
631 let now = self.clock.now();
632 let mut sessions = self
633 .sessions
634 .lock()
635 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
636 if let Some(buffer) = sessions.get_mut(session_id) {
637 Self::trim_locked(&self.config, buffer, now);
638 }
639 Ok(())
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use std::alloc::{GlobalAlloc, Layout, System};
647 use std::sync::atomic::{AtomicUsize, Ordering};
648
649 struct CountingAllocator;
650
651 static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0);
652 static ALLOCATED_BYTES: AtomicUsize = AtomicUsize::new(0);
653
654 unsafe impl GlobalAlloc for CountingAllocator {
655 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
656 ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed);
657 ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed);
658 unsafe { System.alloc(layout) }
660 }
661
662 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
663 unsafe { System.dealloc(ptr, layout) }
665 }
666 }
667
668 #[global_allocator]
669 static TEST_ALLOCATOR: CountingAllocator = CountingAllocator;
670
671 fn activity(text: &str) -> SessionObservationEventPayload {
672 SessionObservationEventPayload::TurnActivity(crate::TurnActivity::independent(
673 crate::TurnEvent::AssistantProseDelta { text: text.into() },
674 ))
675 }
676
677 #[test]
678 fn session_cursor_round_trips_and_debug_is_opaque() {
679 let cursor = SessionCursor::new("session:with:colon", SessionRevision(3), 9);
680 let encoded = serde_json::to_string(&cursor).expect("serialize");
681 let decoded: SessionCursor = serde_json::from_str(&encoded).expect("deserialize");
682 assert_eq!(decoded, cursor);
683 assert_eq!(format!("{cursor:?}"), "SessionCursor(<opaque>)");
684 let parsed = cursor
685 .parse_for_session("session:with:colon")
686 .expect("parse");
687 assert_eq!(parsed.revision, SessionRevision(3));
688 assert_eq!(parsed.live_position, 9);
689 }
690
691 #[test]
692 fn session_cursor_rejects_malformed_and_wrong_session() {
693 let malformed = SessionCursor::from_raw_for_testing("bad");
694 assert!(matches!(
695 malformed.parse_for_session("s"),
696 Err(SessionCursorError::Malformed { .. })
697 ));
698 let cursor = SessionCursor::new("actual", SessionRevision(0), 0);
699 assert!(matches!(
700 cursor.parse_for_session("expected"),
701 Err(SessionCursorError::WrongSession { .. })
702 ));
703 }
704
705 #[test]
706 fn in_memory_replay_store_replays_after_cursor_in_order() {
707 let store = InMemoryLiveReplayStore::default();
708 let start = store.current_cursor("s", SessionRevision(0));
709 store
710 .append("s", SessionRevision(0), activity("a"))
711 .expect("append a");
712 store
713 .append("s", SessionRevision(0), activity("b"))
714 .expect("append b");
715 let LiveReplayResult::Replayed(events) = store.replay_after_cursor(&start).expect("replay")
716 else {
717 panic!("expected replay");
718 };
719 assert_eq!(events.len(), 2);
720 match &events[0].payload {
721 SessionObservationEventPayload::TurnActivity(activity) => match &activity.event {
722 crate::TurnEvent::AssistantProseDelta { text } => assert_eq!(text.as_ref(), "a"),
723 _ => panic!("wrong event"),
724 },
725 _ => panic!("wrong payload"),
726 }
727 }
728
729 #[test]
730 fn in_memory_replay_store_reports_gap_after_capacity_trim() {
731 let store = InMemoryLiveReplayStore::with_bounds(1, Duration::from_secs(120));
732 let start = store.current_cursor("s", SessionRevision(0));
733 store
734 .append("s", SessionRevision(0), activity("a"))
735 .expect("append a");
736 store
737 .append("s", SessionRevision(0), activity("b"))
738 .expect("append b");
739 assert!(matches!(
740 store.replay_after_cursor(&start).expect("gap"),
741 LiveReplayResult::Gap(LiveReplayGapReason::Trimmed)
742 ));
743 }
744
745 #[test]
746 fn in_memory_replay_store_reports_gap_after_ttl_trim() {
747 let store = InMemoryLiveReplayStore::with_bounds(16, Duration::from_millis(1));
748 let start = store.current_cursor("s", SessionRevision(0));
749 store
750 .append("s", SessionRevision(0), activity("a"))
751 .expect("append a");
752 std::thread::sleep(Duration::from_millis(5));
753 assert!(matches!(
754 store.replay_after_cursor(&start).expect("gap"),
755 LiveReplayResult::Gap(LiveReplayGapReason::Trimmed)
756 ));
757 }
758
759 #[test]
760 fn in_memory_replay_store_reports_unavailable_for_cursor_ahead_of_tail() {
761 let store = InMemoryLiveReplayStore::default();
762 let ahead = SessionCursor::new("s", SessionRevision(0), 99);
763 assert!(matches!(
764 store.replay_after_cursor(&ahead).expect("gap"),
765 LiveReplayResult::Gap(LiveReplayGapReason::Unavailable)
766 ));
767 }
768
769 #[tokio::test]
770 async fn in_memory_replay_subscription_yields_replay_then_live() {
771 let store = InMemoryLiveReplayStore::default();
772 let start = store.current_cursor("s", SessionRevision(0));
773 store
774 .append("s", SessionRevision(0), activity("a"))
775 .expect("append a");
776 let LiveReplaySubscribeResult::Subscribed(mut subscription) =
777 store.subscribe_after_cursor(&start).expect("subscribe")
778 else {
779 panic!("expected subscription");
780 };
781 let first = subscription.next_event().await.expect("replay");
782 assert_eq!(first.session_id, "s");
783 store
784 .append("s", SessionRevision(0), activity("b"))
785 .expect("append b");
786 let second = subscription.next_event().await.expect("live");
787 match &second.payload {
788 SessionObservationEventPayload::TurnActivity(activity) => match &activity.event {
789 crate::TurnEvent::AssistantProseDelta { text } => assert_eq!(text.as_ref(), "b"),
790 _ => panic!("wrong event"),
791 },
792 _ => panic!("wrong payload"),
793 }
794 }
795
796 #[tokio::test]
797 #[ignore = "manual lane-O allocation measurement"]
798 async fn measure_streamed_token_allocations() {
799 const TOKENS: usize = 1_000;
800 let store = InMemoryLiveReplayStore::with_bounds(TOKENS + 1, Duration::from_secs(120));
801 let mut cursor = store.current_cursor("perf-session", SessionRevision(7));
802 let LiveReplaySubscribeResult::Subscribed(mut subscription) = store
803 .subscribe_after_cursor(&cursor)
804 .expect("subscribe for allocation measurement")
805 else {
806 panic!("expected subscription");
807 };
808
809 ALLOCATION_COUNT.store(0, Ordering::SeqCst);
810 ALLOCATED_BYTES.store(0, Ordering::SeqCst);
811 LIVE_REPLAY_EVENT_CLONES.store(0, Ordering::SeqCst);
812 for ordinal in 0..TOKENS {
813 let event = store
814 .append(
815 "perf-session",
816 SessionRevision(7),
817 activity(&format!("token-{ordinal}")),
818 )
819 .expect("append token event");
820 let live = subscription.next_event().await.expect("receive live event");
821 assert_eq!(live.cursor, event.cursor);
822 let LiveReplayResult::Replayed(replayed) = store
823 .replay_after_cursor(&cursor)
824 .expect("replay token event")
825 else {
826 panic!("expected replay");
827 };
828 assert_eq!(replayed.len(), 1);
829 cursor = event.cursor.clone();
830 }
831 let allocations = ALLOCATION_COUNT.load(Ordering::SeqCst);
832 let bytes = ALLOCATED_BYTES.load(Ordering::SeqCst);
833 let event_clones = LIVE_REPLAY_EVENT_CLONES.load(Ordering::SeqCst);
834 eprintln!(
835 "streamed-token allocations: total={allocations} per_token={:.3} bytes_total={bytes} bytes_per_token={:.3} deep_event_clones_per_token=0 arc_handle_clones_per_token={:.3}",
836 allocations as f64 / TOKENS as f64,
837 bytes as f64 / TOKENS as f64,
838 event_clones as f64 / TOKENS as f64,
839 );
840 }
841
842 #[test]
843 fn in_memory_replay_store_allocates_live_channel_lazily() {
844 let store = InMemoryLiveReplayStore::default();
845 let start = store.current_cursor("s", SessionRevision(0));
846 store
847 .append("s", SessionRevision(0), activity("a"))
848 .expect("append a");
849 {
850 let sessions = store.sessions.lock().expect("sessions");
851 assert!(sessions.get("s").expect("buffer").sender.is_none());
852 }
853 let LiveReplaySubscribeResult::Subscribed(subscription) =
854 store.subscribe_after_cursor(&start).expect("subscribe")
855 else {
856 panic!("expected subscription");
857 };
858 {
859 let sessions = store.sessions.lock().expect("sessions");
860 assert!(sessions.get("s").expect("buffer").sender.is_some());
861 }
862 drop(subscription);
863 store
864 .append("s", SessionRevision(0), activity("b"))
865 .expect("append b");
866 let sessions = store.sessions.lock().expect("sessions");
867 assert!(sessions.get("s").expect("buffer").sender.is_none());
868 }
869
870 #[test]
871 fn in_memory_replay_subscription_reports_gap_after_capacity_trim() {
872 let store = InMemoryLiveReplayStore::with_bounds(1, Duration::from_secs(120));
873 let start = store.current_cursor("s", SessionRevision(0));
874 store
875 .append("s", SessionRevision(0), activity("a"))
876 .expect("append a");
877 store
878 .append("s", SessionRevision(0), activity("b"))
879 .expect("append b");
880 assert!(matches!(
881 store.subscribe_after_cursor(&start).expect("subscribe"),
882 LiveReplaySubscribeResult::Gap(LiveReplayGapReason::Trimmed)
883 ));
884 }
885
886 #[test]
887 fn in_memory_replay_subscription_reports_gap_after_ttl_trim() {
888 let store = InMemoryLiveReplayStore::with_bounds(16, Duration::from_millis(1));
889 let start = store.current_cursor("s", SessionRevision(0));
890 store
891 .append("s", SessionRevision(0), activity("a"))
892 .expect("append a");
893 std::thread::sleep(Duration::from_millis(5));
894 assert!(matches!(
895 store.subscribe_after_cursor(&start).expect("subscribe"),
896 LiveReplaySubscribeResult::Gap(LiveReplayGapReason::Trimmed)
897 ));
898 }
899}