Skip to main content

sim_lib_stream_combinators/
recording.rs

1use std::ops::RangeBounds;
2use std::sync::{Arc, Mutex};
3
4use sim_kernel::{
5    Cx, Error, Event, EventKind, EventLedger, Ref, Result, Severity, Symbol, Tick, value_from_ref,
6};
7use sim_lib_stream_core::{
8    StreamCassette, StreamDiagnostic, StreamItem, StreamMetadata, StreamPacket, StreamStats,
9    TransportProfile,
10};
11
12use crate::stream::{Stream, StreamNode};
13
14/// A fully captured stream: its metadata plus every packet it produced.
15///
16/// A recording is the materialized, replayable form of a finished stream. It is
17/// produced by draining a [`Stream`] to `done` and can be replayed any number
18/// of times, seeked into, or serialized to a transport cassette.
19///
20/// # Examples
21///
22/// ```
23/// use sim_kernel::{Expr, Symbol};
24/// use sim_lib_stream_core::{
25///     BufferOverflowPolicy, BufferPolicy, StreamDirection, StreamItem, StreamMedia,
26///     StreamMetadata, StreamPacket,
27/// };
28/// use sim_lib_stream_combinators::{record_bang, Stream};
29///
30/// let metadata = StreamMetadata::new(
31///     Symbol::qualified("stream", "doc"),
32///     StreamMedia::Data,
33///     StreamDirection::Source,
34///     Symbol::qualified("clock", "doc"),
35///     BufferPolicy::bounded_with_overflow(8, BufferOverflowPolicy::DropNewest).unwrap(),
36/// );
37/// let item = StreamItem::new(StreamPacket::data(
38///     Symbol::qualified("stream/data", "model-event"),
39///     Expr::Nil,
40/// ));
41/// let stream = Stream::pull(metadata, vec![item.clone()]);
42///
43/// let recording = record_bang(&stream).unwrap();
44/// assert_eq!(recording.len(), 1);
45/// assert_eq!(recording.replay().take_packets(8).unwrap(), vec![item]);
46/// ```
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct StreamRecording {
49    metadata: StreamMetadata,
50    items: Vec<StreamItem>,
51}
52
53impl StreamRecording {
54    /// Builds a recording from explicit metadata and captured packets.
55    pub fn new(metadata: StreamMetadata, items: Vec<StreamItem>) -> Self {
56        Self { metadata, items }
57    }
58
59    /// Returns the metadata of the recorded stream.
60    pub fn metadata(&self) -> &StreamMetadata {
61        &self.metadata
62    }
63
64    /// Returns the captured packets in their recorded order.
65    pub fn items(&self) -> &[StreamItem] {
66        &self.items
67    }
68
69    /// Returns the number of captured packets.
70    pub fn len(&self) -> usize {
71        self.items.len()
72    }
73
74    /// Reports whether the recording captured no packets.
75    pub fn is_empty(&self) -> bool {
76        self.items.is_empty()
77    }
78
79    /// Returns a fresh stream that replays the captured packets.
80    pub fn replay(&self) -> Stream {
81        replay(self)
82    }
83
84    /// Replays the recording from the first packet matching `target`.
85    pub fn seek(&self, target: SeekTarget) -> Stream {
86        seek(self.replay(), target)
87    }
88
89    /// Serializes the recording into a transport cassette for `profile`.
90    pub fn cassette(&self, profile: TransportProfile) -> Result<StreamCassette> {
91        StreamCassette::from_items(
92            self.metadata.clone(),
93            self.items.clone(),
94            profile,
95            StreamStats {
96                yielded: self.items.len() as u64,
97                ..StreamStats::default()
98            },
99        )
100    }
101}
102
103/// Where a [`seek`] should begin replaying within a recorded stream.
104///
105/// # Examples
106///
107/// ```
108/// use sim_lib_stream_combinators::SeekTarget;
109///
110/// let by_index = SeekTarget::packet_index(2);
111/// assert_eq!(by_index, SeekTarget::PacketIndex(2));
112/// ```
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum SeekTarget {
115    /// Start at the packet at this zero-based position in the stream.
116    PacketIndex(usize),
117    /// Start at the first packet bearing `index` on the named `clock`.
118    ClockIndex {
119        /// The clock whose tick index is matched.
120        clock: Symbol,
121        /// The tick index on `clock` to seek to.
122        index: Ref,
123    },
124}
125
126impl SeekTarget {
127    /// Builds a [`SeekTarget::PacketIndex`] for the given position.
128    pub fn packet_index(index: usize) -> Self {
129        Self::PacketIndex(index)
130    }
131
132    /// Builds a [`SeekTarget::ClockIndex`] for the given clock and tick index.
133    pub fn clock_index(clock: Symbol, index: Ref) -> Self {
134        Self::ClockIndex { clock, index }
135    }
136}
137
138/// Default ceiling on the packets captured by the unbounded recorders.
139///
140/// [`record_bang`] and [`record_cassette_bang`] drain a source to `done`; a live
141/// or unbounded source never reaches `done`, so they cap capture at this many
142/// packets and error rather than looping forever. Use [`record_bang_bounded`] /
143/// [`record_cassette_bang_bounded`] to choose an explicit bound for a source
144/// that may not terminate.
145pub const DEFAULT_RECORD_ITEM_LIMIT: usize = 1 << 20;
146
147/// Drains `source` to `done` and captures it as a [`StreamRecording`].
148///
149/// Errors if the stream is exhausted without reaching its terminal `done`, or if
150/// it yields more than [`DEFAULT_RECORD_ITEM_LIMIT`] packets (a guard against a
151/// live or unbounded source that never reaches `done`).
152pub fn record_bang(source: &Stream) -> Result<StreamRecording> {
153    record_bang_bounded(source, DEFAULT_RECORD_ITEM_LIMIT)
154}
155
156/// Drains `source` to `done`, capturing at most `max_items` packets.
157///
158/// Like [`record_bang`] but with a caller-chosen bound. A live or unbounded
159/// source never reaches `done`; recording stops and returns an error once it has
160/// pulled `max_items` packets, so the call cannot loop forever. Prefer this over
161/// [`record_bang`] whenever the source may not terminate.
162pub fn record_bang_bounded(source: &Stream, max_items: usize) -> Result<StreamRecording> {
163    let mut items = Vec::new();
164    while let Some(item) = source.next_packet()? {
165        if items.len() >= max_items {
166            return Err(Error::Eval(format!(
167                "cannot record more than {max_items} packets; source may be live or unbounded"
168            )));
169        }
170        items.push(item);
171    }
172    if !source.is_done()? {
173        return Err(Error::Eval(
174            "cannot record a stream that has not reached done".to_owned(),
175        ));
176    }
177    Ok(StreamRecording::new(source.metadata().clone(), items))
178}
179
180/// Returns a fresh stream replaying every packet of `recording`.
181pub fn replay(recording: &StreamRecording) -> Stream {
182    Stream::pull(recording.metadata.clone(), recording.items.clone())
183}
184
185/// Records `source` to completion and serializes it to a cassette for `profile`.
186///
187/// Capture is bounded by [`DEFAULT_RECORD_ITEM_LIMIT`]; see [`record_bang`].
188pub fn record_cassette_bang(source: &Stream, profile: TransportProfile) -> Result<StreamCassette> {
189    record_bang(source)?.cassette(profile)
190}
191
192/// Records at most `max_items` packets of `source` and serializes them.
193///
194/// Bounded twin of [`record_cassette_bang`] for a live or unbounded source; see
195/// [`record_bang_bounded`].
196pub fn record_cassette_bang_bounded(
197    source: &Stream,
198    profile: TransportProfile,
199    max_items: usize,
200) -> Result<StreamCassette> {
201    record_bang_bounded(source, max_items)?.cassette(profile)
202}
203
204/// Rebuilds a replayable stream from a serialized transport `cassette`.
205pub fn replay_cassette(cassette: &StreamCassette) -> Result<Stream> {
206    Ok(Stream::from_value(Arc::new(
207        cassette.replay_stream_value()?,
208    )))
209}
210
211/// Returns a stream that skips ahead in `source` to the first packet at `target`.
212///
213/// The stream then continues from that packet. If a live source is temporarily
214/// empty before the target arrives, seeking stays pending; if the source reaches
215/// terminal `done` without a match, the seek stream is empty.
216pub fn seek(source: Stream, target: SeekTarget) -> Stream {
217    Stream::new(SeekNode {
218        source,
219        target,
220        state: Mutex::new(SeekState::Pending { skipped: 0 }),
221    })
222}
223
224/// Reconstructs a recording from all of `run`'s events in `ledger`.
225///
226/// Convenience wrapper over [`record_events`] for an entire run.
227pub fn record_ledger_run(
228    cx: &mut Cx,
229    metadata: StreamMetadata,
230    ledger: &EventLedger,
231    run: &Ref,
232) -> Result<StreamRecording> {
233    record_events(cx, metadata, ledger.events_for_run(run))
234}
235
236/// Reconstructs a recording from the events of `run` within `seq_range`.
237///
238/// Like [`record_ledger_run`] but limited to events whose sequence number
239/// falls inside `seq_range`.
240pub fn record_ledger_slice<R>(
241    cx: &mut Cx,
242    metadata: StreamMetadata,
243    ledger: &EventLedger,
244    run: &Ref,
245    seq_range: R,
246) -> Result<StreamRecording>
247where
248    R: RangeBounds<u64>,
249{
250    record_events(
251        cx,
252        metadata,
253        ledger
254            .events_for_run(run)
255            .iter()
256            .filter(|event| seq_range.contains(&event.seq)),
257    )
258}
259
260/// Reconstructs a recording from an arbitrary sequence of kernel `events`.
261///
262/// Chunk events are decoded back into stream packets and diagnostic events into
263/// diagnostic packets; a `done` event ends capture, a `failed` event errors,
264/// and other event kinds are ignored.
265pub fn record_events<'a>(
266    cx: &mut Cx,
267    metadata: StreamMetadata,
268    events: impl IntoIterator<Item = &'a Event>,
269) -> Result<StreamRecording> {
270    let mut items = Vec::new();
271    for event in events {
272        match &event.kind {
273            EventKind::Chunk { payload } => {
274                items.push(item_from_payload(cx, payload, event.ticks.clone())?);
275            }
276            EventKind::Diagnostic(diagnostic) => {
277                items.push(StreamItem::new(StreamPacket::Diagnostic(
278                    diagnostic_packet(diagnostic),
279                )));
280            }
281            EventKind::Done => break,
282            EventKind::Failed(_) => {
283                return Err(Error::Eval(
284                    "cannot record a failed stream event slice".to_owned(),
285                ));
286            }
287            EventKind::Started { .. }
288            | EventKind::Claim { .. }
289            | EventKind::Trace(_)
290            | EventKind::EffectRequested { .. }
291            | EventKind::EffectResolved { .. }
292            | EventKind::Capture { .. }
293            | EventKind::Card { .. }
294            | EventKind::Final(_) => {}
295        }
296    }
297    Ok(StreamRecording::new(metadata, items))
298}
299
300fn item_from_payload(cx: &mut Cx, payload: &Ref, ticks: Vec<Tick>) -> Result<StreamItem> {
301    let value = value_from_ref(cx, payload)?;
302    let packet = StreamPacket::try_from(value.object().as_expr(cx)?)?;
303    StreamItem::with_ticks(packet, ticks)
304}
305
306fn diagnostic_packet(diagnostic: &sim_kernel::Diagnostic) -> StreamDiagnostic {
307    let kind = diagnostic
308        .code
309        .clone()
310        .unwrap_or_else(|| Symbol::qualified("stream/combinator", "Diagnostic"));
311    let prefix = match diagnostic.severity {
312        Severity::Error => "error",
313        Severity::Warning => "warning",
314        Severity::Info => "info",
315        Severity::Note => "note",
316    };
317    StreamDiagnostic::new(kind, format!("{prefix}: {}", diagnostic.message))
318}
319
320struct SeekNode {
321    source: Stream,
322    target: SeekTarget,
323    state: Mutex<SeekState>,
324}
325
326enum SeekState {
327    Pending { skipped: usize },
328    Ready,
329    Drained,
330}
331
332enum SeekPoll {
333    Found(StreamItem),
334    Pending,
335    Drained,
336}
337
338impl StreamNode for SeekNode {
339    fn metadata(&self) -> &StreamMetadata {
340        self.source.metadata()
341    }
342
343    fn next_packet(&self) -> Result<Option<StreamItem>> {
344        let mut state = self
345            .state
346            .lock()
347            .map_err(|_| Error::PoisonedLock("seek stream"))?;
348        match *state {
349            SeekState::Ready => self.source.next_packet(),
350            SeekState::Drained => Ok(None),
351            SeekState::Pending { ref mut skipped } => {
352                let poll = seek_first(&self.source, &self.target, skipped)?;
353                match poll {
354                    SeekPoll::Found(item) => {
355                        *state = SeekState::Ready;
356                        Ok(Some(item))
357                    }
358                    SeekPoll::Pending => Ok(None),
359                    SeekPoll::Drained => {
360                        *state = SeekState::Drained;
361                        Ok(None)
362                    }
363                }
364            }
365        }
366    }
367
368    fn is_done(&self) -> Result<bool> {
369        let state = self
370            .state
371            .lock()
372            .map_err(|_| Error::PoisonedLock("seek stream"))?;
373        match *state {
374            SeekState::Drained => Ok(true),
375            SeekState::Pending { .. } | SeekState::Ready => self.source.is_done(),
376        }
377    }
378}
379
380fn seek_first(source: &Stream, target: &SeekTarget, skipped: &mut usize) -> Result<SeekPoll> {
381    match target {
382        SeekTarget::PacketIndex(index) => {
383            while *skipped < *index {
384                match source.next_packet()? {
385                    Some(_) => *skipped += 1,
386                    None => {
387                        return if source.is_done()? {
388                            Ok(SeekPoll::Drained)
389                        } else {
390                            Ok(SeekPoll::Pending)
391                        };
392                    }
393                }
394            }
395            match source.next_packet()? {
396                Some(item) => Ok(SeekPoll::Found(item)),
397                None => {
398                    if source.is_done()? {
399                        Ok(SeekPoll::Drained)
400                    } else {
401                        Ok(SeekPoll::Pending)
402                    }
403                }
404            }
405        }
406        SeekTarget::ClockIndex { clock, index } => loop {
407            match source.next_packet()? {
408                Some(item) => {
409                    if item
410                        .ticks()
411                        .iter()
412                        .any(|tick| &tick.clock == clock && &tick.index == index)
413                    {
414                        return Ok(SeekPoll::Found(item));
415                    }
416                }
417                None => {
418                    return if source.is_done()? {
419                        Ok(SeekPoll::Drained)
420                    } else {
421                        Ok(SeekPoll::Pending)
422                    };
423                }
424            }
425        },
426    }
427}