Skip to main content

rlx_runtime/
record_replay.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Record/replay middleware (plan #63).
6//!
7//! Borrowed from MAX's `serve/recordreplay/{middleware, replay,
8//! jsonl, schema}.py`. Capture (request, response) pairs to a
9//! JSONL file as production traffic flows through; replay them
10//! against a new build to detect regressions.
11//!
12//! Pure data layer:
13//!   - [`RecordingWriter`] appends [`RecordedExchange`] rows to a
14//!     file (one JSON object per line).
15//!   - [`ReplayReader`] reads them back in declaration order.
16//!   - The actual middleware that wraps a request handler is left
17//!     to the future serving crate — we ship the storage format +
18//!     reader/writer; the wiring is two function calls.
19
20use serde::{Deserialize, Serialize};
21use std::fs::{File, OpenOptions};
22use std::io::{BufRead, BufReader, BufWriter, Write};
23use std::path::Path;
24
25/// One captured request/response pair plus metadata.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RecordedExchange {
28    /// Wall-clock nanoseconds since UNIX epoch when the request
29    /// was received. Replay tools use this to compute relative
30    /// timing if they want to reproduce arrival rates.
31    pub recv_ts_ns: u128,
32    /// Latency of the response in nanoseconds (from request
33    /// receive to response send). `None` if the request didn't
34    /// complete (timeout / cancel / crash).
35    pub latency_ns: Option<u64>,
36    /// Free-form protocol tag (e.g. `"openai.chat"`,
37    /// `"kserve.embedding"`). Replay readers can filter on this.
38    pub protocol: String,
39    /// Wire-format request as JSON.
40    pub request: serde_json::Value,
41    /// Wire-format response as JSON, or `None` for errored / open
42    /// exchanges captured mid-flight.
43    pub response: Option<serde_json::Value>,
44}
45
46impl RecordedExchange {
47    /// Convenience constructor with the current wall clock.
48    pub fn now(
49        protocol: impl Into<String>,
50        request: serde_json::Value,
51        response: Option<serde_json::Value>,
52        latency_ns: Option<u64>,
53    ) -> Self {
54        let recv_ts_ns = std::time::SystemTime::now()
55            .duration_since(std::time::UNIX_EPOCH)
56            .map(|d| d.as_nanos())
57            .unwrap_or(0);
58        Self {
59            recv_ts_ns,
60            latency_ns,
61            protocol: protocol.into(),
62            request,
63            response,
64        }
65    }
66}
67
68/// Append-only JSONL writer. One file = one stream of exchanges.
69/// Cheap drop semantics: flush is best-effort on drop, explicit
70/// `flush` available for callers who need durability.
71pub struct RecordingWriter {
72    out: BufWriter<File>,
73    written: usize,
74}
75
76impl RecordingWriter {
77    /// Open `path`, creating it (or appending if it exists).
78    pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
79        let f = OpenOptions::new().create(true).append(true).open(path)?;
80        Ok(Self {
81            out: BufWriter::new(f),
82            written: 0,
83        })
84    }
85
86    /// Append one exchange. Errors propagate so callers can
87    /// downgrade to "log on failure, don't crash the request".
88    pub fn write(&mut self, ex: &RecordedExchange) -> std::io::Result<()> {
89        let line = serde_json::to_string(ex)
90            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
91        self.out.write_all(line.as_bytes())?;
92        self.out.write_all(b"\n")?;
93        self.written += 1;
94        Ok(())
95    }
96
97    pub fn flush(&mut self) -> std::io::Result<()> {
98        self.out.flush()
99    }
100
101    /// Number of exchanges written this session.
102    pub fn count(&self) -> usize {
103        self.written
104    }
105}
106
107impl Drop for RecordingWriter {
108    fn drop(&mut self) {
109        let _ = self.out.flush();
110    }
111}
112
113/// Iterator-style replay reader. Yields exchanges in file order.
114pub struct ReplayReader {
115    inner: BufReader<File>,
116}
117
118impl ReplayReader {
119    pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
120        let f = File::open(path)?;
121        Ok(Self {
122            inner: BufReader::new(f),
123        })
124    }
125}
126
127impl Iterator for ReplayReader {
128    type Item = std::io::Result<RecordedExchange>;
129    fn next(&mut self) -> Option<Self::Item> {
130        let mut line = String::new();
131        match self.inner.read_line(&mut line) {
132            Ok(0) => None,
133            Ok(_) => {
134                if line.trim().is_empty() {
135                    return self.next();
136                }
137                Some(
138                    serde_json::from_str(line.trim())
139                        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
140                )
141            }
142            Err(e) => Some(Err(e)),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use serde_json::json;
151
152    fn temp_path(label: &str) -> std::path::PathBuf {
153        let dir = std::env::temp_dir();
154        dir.join(format!(
155            "rlx-rr-{label}-{}.jsonl",
156            std::time::SystemTime::now()
157                .duration_since(std::time::UNIX_EPOCH)
158                .unwrap()
159                .as_nanos()
160        ))
161    }
162
163    #[test]
164    fn round_trip_single_exchange() {
165        let path = temp_path("single");
166        {
167            let mut w = RecordingWriter::open(&path).unwrap();
168            let ex = RecordedExchange::now(
169                "openai.chat",
170                json!({ "model": "gpt-4o-mini", "messages": [] }),
171                Some(json!({ "id": "abc", "choices": [] })),
172                Some(123_456),
173            );
174            w.write(&ex).unwrap();
175            assert_eq!(w.count(), 1);
176            w.flush().unwrap();
177        }
178
179        let mut iter = ReplayReader::open(&path).unwrap();
180        let got = iter.next().unwrap().unwrap();
181        assert_eq!(got.protocol, "openai.chat");
182        assert_eq!(got.latency_ns, Some(123_456));
183        assert_eq!(got.request["model"], "gpt-4o-mini");
184        assert!(got.response.is_some());
185        assert!(iter.next().is_none());
186        let _ = std::fs::remove_file(&path);
187    }
188
189    #[test]
190    fn writes_and_reads_in_order() {
191        let path = temp_path("order");
192        {
193            let mut w = RecordingWriter::open(&path).unwrap();
194            for i in 0..5 {
195                w.write(&RecordedExchange::now(
196                    "test",
197                    json!({ "i": i }),
198                    Some(json!({ "i": i })),
199                    Some(i as u64 * 1000),
200                ))
201                .unwrap();
202            }
203        }
204
205        let exchanges: Vec<RecordedExchange> = ReplayReader::open(&path)
206            .unwrap()
207            .collect::<std::io::Result<Vec<_>>>()
208            .unwrap();
209        assert_eq!(exchanges.len(), 5);
210        for (i, ex) in exchanges.iter().enumerate() {
211            assert_eq!(ex.request["i"], i);
212        }
213        let _ = std::fs::remove_file(&path);
214    }
215
216    #[test]
217    fn append_mode_preserves_existing() {
218        let path = temp_path("append");
219        {
220            let mut w = RecordingWriter::open(&path).unwrap();
221            w.write(&RecordedExchange::now(
222                "first",
223                json!({"a": 1}),
224                Some(json!({})),
225                Some(1),
226            ))
227            .unwrap();
228        }
229        {
230            // Re-open: must append, not truncate.
231            let mut w = RecordingWriter::open(&path).unwrap();
232            w.write(&RecordedExchange::now(
233                "second",
234                json!({"a": 2}),
235                Some(json!({})),
236                Some(2),
237            ))
238            .unwrap();
239        }
240
241        let exchanges: Vec<_> = ReplayReader::open(&path)
242            .unwrap()
243            .collect::<std::io::Result<Vec<_>>>()
244            .unwrap();
245        assert_eq!(exchanges.len(), 2);
246        assert_eq!(exchanges[0].protocol, "first");
247        assert_eq!(exchanges[1].protocol, "second");
248        let _ = std::fs::remove_file(&path);
249    }
250
251    #[test]
252    fn missing_response_round_trips() {
253        let path = temp_path("missing-resp");
254        {
255            let mut w = RecordingWriter::open(&path).unwrap();
256            w.write(&RecordedExchange::now(
257                "abandoned",
258                json!({"q": "hello"}),
259                None,
260                None,
261            ))
262            .unwrap();
263        }
264        let ex = ReplayReader::open(&path).unwrap().next().unwrap().unwrap();
265        assert!(ex.response.is_none());
266        assert!(ex.latency_ns.is_none());
267        let _ = std::fs::remove_file(&path);
268    }
269}