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