Skip to main content

brink_runtime/
replay.rs

1//! Replay recording: an in-memory log of external-call results captured during
2//! a live run, replayed back during hot-reload reconstruction so a flow
3//! re-walks the program with faithful external values instead of fallback
4//! approximations.
5//!
6//! Records *every* external uniformly (no pure/query/effect distinction) and
7//! replays the recordings, so replay re-executes nothing — effects don't
8//! double-fire and reads stay faithful. The handlers ([`RecordingHandler`],
9//! [`ReplayHandler`]) *compose* with a real [`ExternalFnHandler`] rather than
10//! threading a recorder through the stepping hot loop. See
11//! `docs/replay-recording-spec.md` (issue #189).
12//!
13//! Recordings live only in memory, for hot-reload — they are not serialized
14//! (the transcript is the durable artifact). They are plain data, so they can
15//! grow serialization later if a consumer ever needs to persist them.
16
17use std::cell::RefCell;
18
19use brink_format::Value;
20
21use crate::story::{ExternalFnHandler, ExternalResult};
22
23/// Upper bound on recorded externals per flow (unbounded-growth guard). Beyond
24/// it, [`ReplayRecorder::record`] drops the result and replay falls through to
25/// the ink fallback body for the uncovered tail.
26pub const RECORDING_CAP: usize = 16_384;
27
28/// One recorded external-function result, captured in call order during a live
29/// run.
30#[derive(Clone, Debug, PartialEq)]
31pub struct RecordedExternal {
32    /// The ink-declared external name.
33    pub name: String,
34    /// Arguments passed, in declaration order.
35    pub args: Vec<Value>,
36    /// The value the external returned.
37    pub result: Value,
38}
39
40/// How a replay obtains external values. Whole-flow granularity.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum ReplayMode {
43    /// Default. Return the recorded result if the next entry matches (name +
44    /// args), else the ink fallback body. Re-executes nothing, so effects don't
45    /// re-fire and reads stay faithful.
46    #[default]
47    Recorded,
48    /// Ignore recordings; run every external live (effects fire). The explicit
49    /// "re-run against the current world" escape hatch — the consumer uses its
50    /// real handler instead of [`ReplayHandler`].
51    Live,
52}
53
54/// An append-only, capped log of external results for one flow, plus a replay
55/// cursor. Recorded during the live run; consumed in order during replay.
56#[derive(Clone, Debug, Default, PartialEq)]
57pub struct ReplayRecorder {
58    log: Vec<RecordedExternal>,
59    cursor: usize,
60    diverged: bool,
61}
62
63impl ReplayRecorder {
64    /// A fresh, empty recorder.
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Append a recorded external result, respecting [`RECORDING_CAP`]. Beyond
71    /// the cap the result is dropped (replay falls through to fallback).
72    pub fn record(&mut self, name: &str, args: &[Value], result: &Value) {
73        if self.log.len() >= RECORDING_CAP {
74            return;
75        }
76        self.log.push(RecordedExternal {
77            name: name.to_owned(),
78            args: args.to_vec(),
79            result: result.clone(),
80        });
81    }
82
83    /// Replay-cursor lookup: if the next recorded entry matches `name` + `args`,
84    /// return its result and advance the cursor. On the first mismatch (the
85    /// program path changed under us) or exhaustion, mark the recorder diverged
86    /// so every subsequent lookup returns `None` (→ fallback), rather than
87    /// feeding misaligned later recordings.
88    pub fn take_recorded(&mut self, name: &str, args: &[Value]) -> Option<Value> {
89        if self.diverged {
90            return None;
91        }
92        match self.log.get(self.cursor) {
93            Some(entry) if entry.name == name && entry.args.as_slice() == args => {
94                self.cursor += 1;
95                Some(entry.result.clone())
96            }
97            _ => {
98                self.diverged = true;
99                None
100            }
101        }
102    }
103
104    /// Reset the replay cursor and divergence flag to the start of the log, so
105    /// the recording can drive another replay from the beginning.
106    pub fn reset_cursor(&mut self) {
107        self.cursor = 0;
108        self.diverged = false;
109    }
110
111    /// Number of recorded externals.
112    #[must_use]
113    pub fn len(&self) -> usize {
114        self.log.len()
115    }
116
117    /// Whether nothing has been recorded.
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.log.is_empty()
121    }
122}
123
124/// Wraps an [`ExternalFnHandler`] and records every inline-`Resolved` external
125/// result into a [`ReplayRecorder`] during a live run.
126///
127/// Pure/command bindings resolve inline and are captured here. World-access /
128/// async bindings resolve *out of band* (the handler returns
129/// [`ExternalResult::Pending`] and the value arrives later via
130/// `resolve_external`), so the consumer records those itself when it supplies
131/// the value — it has the name, args, and result at that point.
132pub struct RecordingHandler<'a, H: ExternalFnHandler + ?Sized> {
133    inner: &'a H,
134    recorder: RefCell<&'a mut ReplayRecorder>,
135}
136
137impl<'a, H: ExternalFnHandler + ?Sized> RecordingHandler<'a, H> {
138    /// Wrap `inner`, recording its inline-`Resolved` results into `recorder`.
139    pub fn new(inner: &'a H, recorder: &'a mut ReplayRecorder) -> Self {
140        Self {
141            inner,
142            recorder: RefCell::new(recorder),
143        }
144    }
145}
146
147impl<H: ExternalFnHandler + ?Sized> ExternalFnHandler for RecordingHandler<'_, H> {
148    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
149        let result = self.inner.call(name, args);
150        if let ExternalResult::Resolved(value) = &result {
151            self.recorder.borrow_mut().record(name, args, value);
152        }
153        result
154    }
155}
156
157/// Replays recorded external results (`ReplayMode::Recorded`).
158///
159/// For each call, returns the next recorded result if it matches (name + args),
160/// else [`ExternalResult::Fallback`] — the ink fallback body — for
161/// uncovered / divergent / past-cap calls. Re-executes nothing, so effects
162/// don't re-fire and reads stay faithful.
163///
164/// For `ReplayMode::Live`, don't use this handler: supply the consumer's real
165/// handler instead so everything runs live.
166pub struct ReplayHandler<'a> {
167    recorder: RefCell<&'a mut ReplayRecorder>,
168}
169
170impl<'a> ReplayHandler<'a> {
171    /// Build a replay handler over `recorder`, resetting its cursor so replay
172    /// starts from the first recorded result.
173    pub fn new(recorder: &'a mut ReplayRecorder) -> Self {
174        recorder.reset_cursor();
175        Self {
176            recorder: RefCell::new(recorder),
177        }
178    }
179}
180
181impl ExternalFnHandler for ReplayHandler<'_> {
182    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
183        match self.recorder.borrow_mut().take_recorded(name, args) {
184            Some(value) => ExternalResult::Resolved(value),
185            None => ExternalResult::Fallback,
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn args(xs: &[i32]) -> Vec<Value> {
195        xs.iter().map(|&x| Value::Int(x)).collect()
196    }
197
198    #[test]
199    fn records_and_replays_in_order() {
200        let mut r = ReplayRecorder::new();
201        r.record("get_switch", &args(&[1]), &Value::Bool(true));
202        r.record("get_var", &args(&[2]), &Value::Int(42));
203        assert_eq!(r.len(), 2);
204
205        assert_eq!(
206            r.take_recorded("get_switch", &args(&[1])),
207            Some(Value::Bool(true))
208        );
209        assert_eq!(
210            r.take_recorded("get_var", &args(&[2])),
211            Some(Value::Int(42))
212        );
213        // Exhausted → fallback.
214        assert_eq!(r.take_recorded("get_var", &args(&[2])), None);
215    }
216
217    #[test]
218    fn diverges_on_mismatch_and_stays_diverged() {
219        let mut r = ReplayRecorder::new();
220        r.record("a", &args(&[1]), &Value::Int(1));
221        r.record("b", &args(&[2]), &Value::Int(2));
222        assert_eq!(r.take_recorded("x", &args(&[1])), None);
223        // Diverged latches: even a would-be match now returns None.
224        assert_eq!(r.take_recorded("a", &args(&[1])), None);
225    }
226
227    #[test]
228    fn arg_mismatch_diverges() {
229        let mut r = ReplayRecorder::new();
230        r.record("get_switch", &args(&[1]), &Value::Bool(true));
231        assert_eq!(r.take_recorded("get_switch", &args(&[2])), None);
232    }
233
234    #[test]
235    fn reset_cursor_replays_again() {
236        let mut r = ReplayRecorder::new();
237        r.record("a", &args(&[1]), &Value::Int(7));
238        assert_eq!(r.take_recorded("a", &args(&[1])), Some(Value::Int(7)));
239        r.reset_cursor();
240        assert_eq!(r.take_recorded("a", &args(&[1])), Some(Value::Int(7)));
241    }
242
243    #[test]
244    fn cap_drops_beyond_limit() {
245        let mut r = ReplayRecorder::new();
246        for _ in 0..RECORDING_CAP + 10 {
247            r.record("a", &[], &Value::Null);
248        }
249        assert_eq!(r.len(), RECORDING_CAP);
250    }
251
252    /// A stub handler: `Resolved` for names in its table, else `Fallback`.
253    struct Stub(Vec<(&'static str, Value)>);
254    impl ExternalFnHandler for Stub {
255        fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
256            self.0
257                .iter()
258                .find(|(n, _)| *n == name)
259                .map_or(ExternalResult::Fallback, |(_, v)| {
260                    ExternalResult::Resolved(v.clone())
261                })
262        }
263    }
264
265    #[test]
266    fn recording_captures_resolved_passes_through_fallback() {
267        let mut rec = ReplayRecorder::new();
268        let inner = Stub(vec![("get", Value::Int(5))]);
269        {
270            let h = RecordingHandler::new(&inner, &mut rec);
271            assert!(matches!(h.call("get", &[]), ExternalResult::Resolved(_)));
272            assert!(matches!(h.call("nope", &[]), ExternalResult::Fallback));
273        }
274        assert_eq!(rec.len(), 1);
275    }
276
277    #[test]
278    fn replay_returns_recorded_then_fallback() {
279        let mut rec = ReplayRecorder::new();
280        rec.record("get", &[], &Value::Int(5));
281        let h = ReplayHandler::new(&mut rec);
282        assert!(matches!(
283            h.call("get", &[]),
284            ExternalResult::Resolved(Value::Int(5))
285        ));
286        assert!(matches!(h.call("get", &[]), ExternalResult::Fallback));
287    }
288
289    #[test]
290    fn record_then_replay_roundtrip() {
291        let mut rec = ReplayRecorder::new();
292        let inner = Stub(vec![("a", Value::Int(1)), ("b", Value::Bool(true))]);
293        {
294            let h = RecordingHandler::new(&inner, &mut rec);
295            let _ = h.call("a", &[]);
296            let _ = h.call("b", &[]);
297        }
298        let h = ReplayHandler::new(&mut rec);
299        assert!(matches!(
300            h.call("a", &[]),
301            ExternalResult::Resolved(Value::Int(1))
302        ));
303        assert!(matches!(
304            h.call("b", &[]),
305            ExternalResult::Resolved(Value::Bool(true))
306        ));
307    }
308
309    #[test]
310    fn replay_diverges_to_fallback_on_mismatch() {
311        let mut rec = ReplayRecorder::new();
312        rec.record("a", &[], &Value::Int(1));
313        let h = ReplayHandler::new(&mut rec);
314        assert!(matches!(h.call("x", &[]), ExternalResult::Fallback));
315        assert!(matches!(h.call("a", &[]), ExternalResult::Fallback));
316    }
317}