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)]
191pub enum SessionObservationEventPayload {
192 TurnActivity(crate::TurnActivity),
193 Committed {
194 read_view: crate::SessionReadView,
195 },
196 AgentFrameSwitched {
197 frame_id: String,
198 },
199 QueueChanged {
200 kind: SessionQueueEventKind,
201 batch_ids: Vec<String>,
202 },
203 ProcessChanged {
204 kind: SessionProcessEventKind,
205 process_ids: Vec<String>,
206 },
207}
208
209#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
210pub struct LiveReplayGap {
211 pub session_id: String,
212 pub requested_cursor: SessionCursor,
213 pub latest_cursor: SessionCursor,
214 pub latest_revision: SessionRevision,
215 pub reason: LiveReplayGapReason,
216}
217
218#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum LiveReplayGapReason {
221 Trimmed,
222 Unavailable,
223}
224
225#[derive(Clone, Debug, thiserror::Error)]
226pub enum LiveReplayStoreError {
227 #[error("{0}")]
228 Cursor(#[from] SessionCursorError),
229 #[error("live replay store error: {0}")]
230 Store(String),
231 #[error("live replay subscriber lagged by {0} events")]
232 SubscriberLagged(u64),
233 #[error("live replay channel closed")]
234 Closed,
235}
236
237#[derive(Clone, Debug)]
238pub enum LiveReplayResult {
239 Replayed(Vec<Arc<SessionObservationEvent>>),
240 Gap(LiveReplayGapReason),
241}
242
243pub enum LiveReplaySubscribeResult {
244 Subscribed(LiveReplaySubscription),
245 Gap(LiveReplayGapReason),
246}
247
248type LiveReplayRecvResult = (
249 Result<Arc<SessionObservationEvent>, broadcast::error::RecvError>,
250 broadcast::Receiver<Arc<SessionObservationEvent>>,
251);
252
253#[cfg(test)]
254static LIVE_REPLAY_EVENT_CLONES: std::sync::atomic::AtomicUsize =
255 std::sync::atomic::AtomicUsize::new(0);
256
257#[inline]
258fn clone_event(event: &Arc<SessionObservationEvent>) -> Arc<SessionObservationEvent> {
259 #[cfg(test)]
260 LIVE_REPLAY_EVENT_CLONES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
261 Arc::clone(event)
262}
263
264pub struct LiveReplaySubscription {
265 replay: VecDeque<Arc<SessionObservationEvent>>,
266 receiver: ReusableBoxFuture<'static, LiveReplayRecvResult>,
267 closed: bool,
268}
269
270impl LiveReplaySubscription {
271 fn new(
272 replay: Vec<Arc<SessionObservationEvent>>,
273 receiver: broadcast::Receiver<Arc<SessionObservationEvent>>,
274 ) -> Self {
275 Self {
276 replay: replay.into(),
277 receiver: ReusableBoxFuture::new(live_replay_recv(receiver)),
278 closed: false,
279 }
280 }
281
282 pub async fn next_event(
283 &mut self,
284 ) -> Result<Arc<SessionObservationEvent>, LiveReplayStoreError> {
285 futures_util::StreamExt::next(self)
286 .await
287 .unwrap_or(Err(LiveReplayStoreError::Closed))
288 }
289}
290
291async fn live_replay_recv(
292 mut receiver: broadcast::Receiver<Arc<SessionObservationEvent>>,
293) -> LiveReplayRecvResult {
294 let result = receiver.recv().await;
295 #[cfg(test)]
296 if result.is_ok() {
297 LIVE_REPLAY_EVENT_CLONES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
298 }
299 (result, receiver)
300}
301
302impl Stream for LiveReplaySubscription {
303 type Item = Result<Arc<SessionObservationEvent>, LiveReplayStoreError>;
304
305 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
306 if let Some(event) = self.replay.pop_front() {
307 return Poll::Ready(Some(Ok(event)));
308 }
309 if self.closed {
310 return Poll::Ready(None);
311 }
312 let (result, receiver) = ready!(self.receiver.poll(cx));
313 self.receiver.set(live_replay_recv(receiver));
314 match result {
315 Ok(event) => Poll::Ready(Some(Ok(event))),
316 Err(broadcast::error::RecvError::Lagged(count)) => {
317 Poll::Ready(Some(Err(LiveReplayStoreError::SubscriberLagged(count))))
318 }
319 Err(broadcast::error::RecvError::Closed) => {
320 self.closed = true;
321 Poll::Ready(Some(Err(LiveReplayStoreError::Closed)))
322 }
323 }
324 }
325}
326
327#[derive(Clone, Debug)]
328pub enum SessionResume {
329 Replayed {
330 events: Vec<Arc<SessionObservationEvent>>,
331 },
332 Gap {
333 observation: SessionObservation,
334 gap: LiveReplayGap,
335 },
336}
337
338pub enum SessionObservationSubscription {
339 Subscribed(LiveReplaySubscription),
340 Gap {
341 observation: SessionObservation,
342 gap: LiveReplayGap,
343 },
344}
345
346pub trait LiveReplayStore: Send + Sync {
354 fn append(
358 &self,
359 session_id: &str,
360 revision: SessionRevision,
361 payload: SessionObservationEventPayload,
362 ) -> Result<Arc<SessionObservationEvent>, LiveReplayStoreError>;
363
364 fn replay_after_cursor(
368 &self,
369 cursor: &SessionCursor,
370 ) -> Result<LiveReplayResult, LiveReplayStoreError>;
371
372 fn subscribe_after_cursor(
376 &self,
377 cursor: &SessionCursor,
378 ) -> Result<LiveReplaySubscribeResult, LiveReplayStoreError>;
379
380 fn current_cursor(&self, session_id: &str, revision: SessionRevision) -> SessionCursor;
384
385 fn trim_session(&self, session_id: &str) -> Result<(), LiveReplayStoreError>;
389}
390
391#[derive(Clone, Debug)]
392pub struct InMemoryLiveReplayStoreConfig {
393 pub max_events_per_session: usize,
394 pub max_age: Duration,
395}
396
397impl Default for InMemoryLiveReplayStoreConfig {
398 fn default() -> Self {
399 Self {
400 max_events_per_session: DEFAULT_LIVE_REPLAY_CAPACITY,
401 max_age: DEFAULT_LIVE_REPLAY_TTL,
402 }
403 }
404}
405
406#[derive(Debug)]
407pub struct InMemoryLiveReplayStore {
408 config: InMemoryLiveReplayStoreConfig,
409 clock: Arc<dyn crate::Clock>,
410 sessions: StdMutex<HashMap<String, LiveReplaySessionBuffer>>,
411}
412
413impl InMemoryLiveReplayStore {
414 pub fn new(config: InMemoryLiveReplayStoreConfig) -> Self {
415 Self::with_clock(config, Arc::new(crate::SystemClock))
416 }
417
418 pub fn with_clock(config: InMemoryLiveReplayStoreConfig, clock: Arc<dyn crate::Clock>) -> Self {
419 Self {
420 config,
421 clock,
422 sessions: StdMutex::new(HashMap::new()),
423 }
424 }
425
426 pub fn with_bounds(max_events_per_session: usize, max_age: Duration) -> Self {
427 Self::new(InMemoryLiveReplayStoreConfig {
428 max_events_per_session,
429 max_age,
430 })
431 }
432}
433
434impl Default for InMemoryLiveReplayStore {
435 fn default() -> Self {
436 Self::new(InMemoryLiveReplayStoreConfig::default())
437 }
438}
439
440#[derive(Debug)]
441struct LiveReplaySessionBuffer {
442 events: VecDeque<StoredObservationEvent>,
443 tail_position: u64,
444 sender: Option<broadcast::Sender<Arc<SessionObservationEvent>>>,
445}
446
447impl LiveReplaySessionBuffer {
448 fn new() -> Self {
449 Self {
450 events: VecDeque::new(),
451 tail_position: 0,
452 sender: None,
453 }
454 }
455
456 fn subscribe(
457 &mut self,
458 channel_capacity: usize,
459 ) -> broadcast::Receiver<Arc<SessionObservationEvent>> {
460 match self.sender.as_ref() {
461 Some(sender) => sender.subscribe(),
462 None => {
463 let (sender, receiver) = broadcast::channel(channel_capacity.max(1));
464 self.sender = Some(sender);
465 receiver
466 }
467 }
468 }
469
470 fn publish(&mut self, event: Arc<SessionObservationEvent>) {
471 let Some(sender) = self.sender.as_ref() else {
472 return;
473 };
474 if sender.send(event).is_err() {
475 self.sender = None;
476 }
477 }
478}
479
480#[derive(Clone, Debug)]
481struct StoredObservationEvent {
482 position: u64,
483 appended_at: Instant,
484 event: Arc<SessionObservationEvent>,
485}
486
487impl InMemoryLiveReplayStore {
488 fn trim_locked(
489 config: &InMemoryLiveReplayStoreConfig,
490 buffer: &mut LiveReplaySessionBuffer,
491 now: Instant,
492 ) {
493 while buffer.events.len() > config.max_events_per_session {
494 buffer.events.pop_front();
495 }
496 while buffer
497 .events
498 .front()
499 .is_some_and(|event| now.duration_since(event.appended_at) > config.max_age)
500 {
501 buffer.events.pop_front();
502 }
503 }
504
505 fn gap_reason_for_cursor(
506 buffer: Option<&LiveReplaySessionBuffer>,
507 cursor_position: u64,
508 ) -> Option<LiveReplayGapReason> {
509 let Some(buffer) = buffer else {
510 return (cursor_position > 0).then_some(LiveReplayGapReason::Unavailable);
511 };
512 if cursor_position > buffer.tail_position {
513 return Some(LiveReplayGapReason::Unavailable);
514 }
515 let Some(first) = buffer.events.front() else {
516 return (cursor_position < buffer.tail_position)
517 .then_some(LiveReplayGapReason::Trimmed);
518 };
519 if cursor_position + 1 < first.position {
520 Some(LiveReplayGapReason::Trimmed)
521 } else {
522 None
523 }
524 }
525}
526
527impl LiveReplayStore for InMemoryLiveReplayStore {
528 fn append(
529 &self,
530 session_id: &str,
531 revision: SessionRevision,
532 payload: SessionObservationEventPayload,
533 ) -> Result<Arc<SessionObservationEvent>, LiveReplayStoreError> {
534 let now = self.clock.now();
535 let mut sessions = self
536 .sessions
537 .lock()
538 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
539 let buffer = sessions
540 .entry(session_id.to_string())
541 .or_insert_with(LiveReplaySessionBuffer::new);
542 buffer.tail_position = buffer.tail_position.saturating_add(1);
543 let cursor = SessionCursor::new(session_id, revision, buffer.tail_position);
544 let event = Arc::new(SessionObservationEvent {
545 session_id: session_id.to_string(),
546 revision,
547 cursor,
548 payload,
549 });
550 buffer.events.push_back(StoredObservationEvent {
551 position: buffer.tail_position,
552 appended_at: now,
553 event: clone_event(&event),
554 });
555 Self::trim_locked(&self.config, buffer, now);
556 buffer.publish(clone_event(&event));
557 Ok(event)
558 }
559
560 fn replay_after_cursor(
561 &self,
562 cursor: &SessionCursor,
563 ) -> Result<LiveReplayResult, LiveReplayStoreError> {
564 let parsed = cursor.parse()?;
565 let _cursor_revision = parsed.revision;
566 let now = self.clock.now();
567 let mut sessions = self
568 .sessions
569 .lock()
570 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
571 if let Some(buffer) = sessions.get_mut(&parsed.session_id) {
572 Self::trim_locked(&self.config, buffer, now);
573 }
574 let buffer = sessions.get(&parsed.session_id);
575 if let Some(reason) = Self::gap_reason_for_cursor(buffer, parsed.live_position) {
576 return Ok(LiveReplayResult::Gap(reason));
577 }
578 let events = buffer
579 .map(|buffer| {
580 buffer
581 .events
582 .iter()
583 .filter(|event| event.position > parsed.live_position)
584 .map(|event| clone_event(&event.event))
585 .collect()
586 })
587 .unwrap_or_default();
588 Ok(LiveReplayResult::Replayed(events))
589 }
590
591 fn subscribe_after_cursor(
592 &self,
593 cursor: &SessionCursor,
594 ) -> Result<LiveReplaySubscribeResult, LiveReplayStoreError> {
595 let parsed = cursor.parse()?;
596 let _cursor_revision = parsed.revision;
597 let now = self.clock.now();
598 let mut sessions = self
599 .sessions
600 .lock()
601 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
602 let buffer = sessions
603 .entry(parsed.session_id.clone())
604 .or_insert_with(LiveReplaySessionBuffer::new);
605 Self::trim_locked(&self.config, buffer, now);
606 if let Some(reason) = Self::gap_reason_for_cursor(Some(buffer), parsed.live_position) {
607 return Ok(LiveReplaySubscribeResult::Gap(reason));
608 }
609 let replay = buffer
610 .events
611 .iter()
612 .filter(|event| event.position > parsed.live_position)
613 .map(|event| clone_event(&event.event))
614 .collect();
615 let receiver = buffer.subscribe(self.config.max_events_per_session);
616 Ok(LiveReplaySubscribeResult::Subscribed(
617 LiveReplaySubscription::new(replay, receiver),
618 ))
619 }
620
621 fn current_cursor(&self, session_id: &str, revision: SessionRevision) -> SessionCursor {
622 let tail_position = self
623 .sessions
624 .lock()
625 .ok()
626 .and_then(|sessions| sessions.get(session_id).map(|buffer| buffer.tail_position))
627 .unwrap_or(0);
628 SessionCursor::new(session_id, revision, tail_position)
629 }
630
631 fn trim_session(&self, session_id: &str) -> Result<(), LiveReplayStoreError> {
632 let now = self.clock.now();
633 let mut sessions = self
634 .sessions
635 .lock()
636 .map_err(|_| LiveReplayStoreError::Store("live replay mutex poisoned".to_string()))?;
637 if let Some(buffer) = sessions.get_mut(session_id) {
638 Self::trim_locked(&self.config, buffer, now);
639 }
640 Ok(())
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use std::alloc::{GlobalAlloc, Layout, System};
648 use std::sync::atomic::{AtomicUsize, Ordering};
649
650 struct CountingAllocator;
651
652 static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0);
653 static ALLOCATED_BYTES: AtomicUsize = AtomicUsize::new(0);
654
655 unsafe impl GlobalAlloc for CountingAllocator {
656 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
657 ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed);
658 ALLOCATED_BYTES.fetch_add(layout.size(), Ordering::Relaxed);
659 unsafe { System.alloc(layout) }
661 }
662
663 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
664 unsafe { System.dealloc(ptr, layout) }
666 }
667 }
668
669 #[global_allocator]
670 static TEST_ALLOCATOR: CountingAllocator = CountingAllocator;
671
672 fn activity(text: &str) -> SessionObservationEventPayload {
673 SessionObservationEventPayload::TurnActivity(crate::TurnActivity::independent(
674 crate::TurnEvent::AssistantProseDelta { text: text.into() },
675 ))
676 }
677
678 #[test]
679 fn session_cursor_round_trips_and_debug_is_opaque() {
680 let cursor = SessionCursor::new("session:with:colon", SessionRevision(3), 9);
681 let encoded = serde_json::to_string(&cursor).expect("serialize");
682 let decoded: SessionCursor = serde_json::from_str(&encoded).expect("deserialize");
683 assert_eq!(decoded, cursor);
684 assert_eq!(format!("{cursor:?}"), "SessionCursor(<opaque>)");
685 let parsed = cursor
686 .parse_for_session("session:with:colon")
687 .expect("parse");
688 assert_eq!(parsed.revision, SessionRevision(3));
689 assert_eq!(parsed.live_position, 9);
690 }
691
692 #[test]
693 fn session_cursor_rejects_malformed_and_wrong_session() {
694 let malformed = SessionCursor::from_raw_for_testing("bad");
695 assert!(matches!(
696 malformed.parse_for_session("s"),
697 Err(SessionCursorError::Malformed { .. })
698 ));
699 let cursor = SessionCursor::new("actual", SessionRevision(0), 0);
700 assert!(matches!(
701 cursor.parse_for_session("expected"),
702 Err(SessionCursorError::WrongSession { .. })
703 ));
704 }
705
706 #[test]
707 fn in_memory_replay_store_replays_after_cursor_in_order() {
708 let store = InMemoryLiveReplayStore::default();
709 let start = store.current_cursor("s", SessionRevision(0));
710 store
711 .append("s", SessionRevision(0), activity("a"))
712 .expect("append a");
713 store
714 .append("s", SessionRevision(0), activity("b"))
715 .expect("append b");
716 let LiveReplayResult::Replayed(events) = store.replay_after_cursor(&start).expect("replay")
717 else {
718 panic!("expected replay");
719 };
720 assert_eq!(events.len(), 2);
721 match &events[0].payload {
722 SessionObservationEventPayload::TurnActivity(activity) => match &activity.event {
723 crate::TurnEvent::AssistantProseDelta { text } => assert_eq!(text.as_ref(), "a"),
724 _ => panic!("wrong event"),
725 },
726 _ => panic!("wrong payload"),
727 }
728 }
729
730 #[test]
731 fn in_memory_replay_store_reports_gap_after_capacity_trim() {
732 let store = InMemoryLiveReplayStore::with_bounds(1, Duration::from_secs(120));
733 let start = store.current_cursor("s", SessionRevision(0));
734 store
735 .append("s", SessionRevision(0), activity("a"))
736 .expect("append a");
737 store
738 .append("s", SessionRevision(0), activity("b"))
739 .expect("append b");
740 assert!(matches!(
741 store.replay_after_cursor(&start).expect("gap"),
742 LiveReplayResult::Gap(LiveReplayGapReason::Trimmed)
743 ));
744 }
745
746 #[test]
747 fn in_memory_replay_store_reports_gap_after_ttl_trim() {
748 let store = InMemoryLiveReplayStore::with_bounds(16, Duration::from_millis(1));
749 let start = store.current_cursor("s", SessionRevision(0));
750 store
751 .append("s", SessionRevision(0), activity("a"))
752 .expect("append a");
753 std::thread::sleep(Duration::from_millis(5));
754 assert!(matches!(
755 store.replay_after_cursor(&start).expect("gap"),
756 LiveReplayResult::Gap(LiveReplayGapReason::Trimmed)
757 ));
758 }
759
760 #[test]
761 fn in_memory_replay_store_reports_unavailable_for_cursor_ahead_of_tail() {
762 let store = InMemoryLiveReplayStore::default();
763 let ahead = SessionCursor::new("s", SessionRevision(0), 99);
764 assert!(matches!(
765 store.replay_after_cursor(&ahead).expect("gap"),
766 LiveReplayResult::Gap(LiveReplayGapReason::Unavailable)
767 ));
768 }
769
770 #[tokio::test]
771 async fn in_memory_replay_subscription_yields_replay_then_live() {
772 let store = InMemoryLiveReplayStore::default();
773 let start = store.current_cursor("s", SessionRevision(0));
774 store
775 .append("s", SessionRevision(0), activity("a"))
776 .expect("append a");
777 let LiveReplaySubscribeResult::Subscribed(mut subscription) =
778 store.subscribe_after_cursor(&start).expect("subscribe")
779 else {
780 panic!("expected subscription");
781 };
782 let first = subscription.next_event().await.expect("replay");
783 assert_eq!(first.session_id, "s");
784 store
785 .append("s", SessionRevision(0), activity("b"))
786 .expect("append b");
787 let second = subscription.next_event().await.expect("live");
788 match &second.payload {
789 SessionObservationEventPayload::TurnActivity(activity) => match &activity.event {
790 crate::TurnEvent::AssistantProseDelta { text } => assert_eq!(text.as_ref(), "b"),
791 _ => panic!("wrong event"),
792 },
793 _ => panic!("wrong payload"),
794 }
795 }
796
797 #[tokio::test]
798 #[ignore = "manual lane-O allocation measurement"]
799 async fn measure_streamed_token_allocations() {
800 const TOKENS: usize = 1_000;
801 let store = InMemoryLiveReplayStore::with_bounds(TOKENS + 1, Duration::from_secs(120));
802 let mut cursor = store.current_cursor("perf-session", SessionRevision(7));
803 let LiveReplaySubscribeResult::Subscribed(mut subscription) = store
804 .subscribe_after_cursor(&cursor)
805 .expect("subscribe for allocation measurement")
806 else {
807 panic!("expected subscription");
808 };
809
810 ALLOCATION_COUNT.store(0, Ordering::SeqCst);
811 ALLOCATED_BYTES.store(0, Ordering::SeqCst);
812 LIVE_REPLAY_EVENT_CLONES.store(0, Ordering::SeqCst);
813 for ordinal in 0..TOKENS {
814 let event = store
815 .append(
816 "perf-session",
817 SessionRevision(7),
818 activity(&format!("token-{ordinal}")),
819 )
820 .expect("append token event");
821 let live = subscription.next_event().await.expect("receive live event");
822 assert_eq!(live.cursor, event.cursor);
823 let LiveReplayResult::Replayed(replayed) = store
824 .replay_after_cursor(&cursor)
825 .expect("replay token event")
826 else {
827 panic!("expected replay");
828 };
829 assert_eq!(replayed.len(), 1);
830 cursor = event.cursor.clone();
831 }
832 let allocations = ALLOCATION_COUNT.load(Ordering::SeqCst);
833 let bytes = ALLOCATED_BYTES.load(Ordering::SeqCst);
834 let event_clones = LIVE_REPLAY_EVENT_CLONES.load(Ordering::SeqCst);
835 eprintln!(
836 "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}",
837 allocations as f64 / TOKENS as f64,
838 bytes as f64 / TOKENS as f64,
839 event_clones as f64 / TOKENS as f64,
840 );
841 }
842
843 #[test]
844 fn in_memory_replay_store_allocates_live_channel_lazily() {
845 let store = InMemoryLiveReplayStore::default();
846 let start = store.current_cursor("s", SessionRevision(0));
847 store
848 .append("s", SessionRevision(0), activity("a"))
849 .expect("append a");
850 {
851 let sessions = store.sessions.lock().expect("sessions");
852 assert!(sessions.get("s").expect("buffer").sender.is_none());
853 }
854 let LiveReplaySubscribeResult::Subscribed(subscription) =
855 store.subscribe_after_cursor(&start).expect("subscribe")
856 else {
857 panic!("expected subscription");
858 };
859 {
860 let sessions = store.sessions.lock().expect("sessions");
861 assert!(sessions.get("s").expect("buffer").sender.is_some());
862 }
863 drop(subscription);
864 store
865 .append("s", SessionRevision(0), activity("b"))
866 .expect("append b");
867 let sessions = store.sessions.lock().expect("sessions");
868 assert!(sessions.get("s").expect("buffer").sender.is_none());
869 }
870
871 #[test]
872 fn in_memory_replay_subscription_reports_gap_after_capacity_trim() {
873 let store = InMemoryLiveReplayStore::with_bounds(1, Duration::from_secs(120));
874 let start = store.current_cursor("s", SessionRevision(0));
875 store
876 .append("s", SessionRevision(0), activity("a"))
877 .expect("append a");
878 store
879 .append("s", SessionRevision(0), activity("b"))
880 .expect("append b");
881 assert!(matches!(
882 store.subscribe_after_cursor(&start).expect("subscribe"),
883 LiveReplaySubscribeResult::Gap(LiveReplayGapReason::Trimmed)
884 ));
885 }
886
887 #[test]
888 fn in_memory_replay_subscription_reports_gap_after_ttl_trim() {
889 let store = InMemoryLiveReplayStore::with_bounds(16, Duration::from_millis(1));
890 let start = store.current_cursor("s", SessionRevision(0));
891 store
892 .append("s", SessionRevision(0), activity("a"))
893 .expect("append a");
894 std::thread::sleep(Duration::from_millis(5));
895 assert!(matches!(
896 store.subscribe_after_cursor(&start).expect("subscribe"),
897 LiveReplaySubscribeResult::Gap(LiveReplayGapReason::Trimmed)
898 ));
899 }
900}