rlx_runtime/
record_replay.rs1use serde::{Deserialize, Serialize};
21use std::fs::{File, OpenOptions};
22use std::io::{BufRead, BufReader, BufWriter, Write};
23use std::path::Path;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RecordedExchange {
28 pub recv_ts_ns: u128,
32 pub latency_ns: Option<u64>,
36 pub protocol: String,
39 pub request: serde_json::Value,
41 pub response: Option<serde_json::Value>,
44}
45
46impl RecordedExchange {
47 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
68pub struct RecordingWriter {
72 out: BufWriter<File>,
73 written: usize,
74}
75
76impl RecordingWriter {
77 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 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 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
113pub 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 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}