1use anyhow::{Context, Result};
15use std::fs::OpenOptions;
16use std::io::{BufWriter, Write};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20use std::time::Duration;
21
22use tokio::sync::{mpsc, oneshot};
23use tokio::task::spawn_blocking;
24use tokio::time::{self, MissedTickBehavior};
25
26use crate::config::constants::defaults;
27use crate::utils::file_utils::ensure_dir_exists_sync;
28
29enum LogMessage {
30 Line { line: String, bytes: usize },
31 Flush(oneshot::Sender<bool>),
32}
33
34#[derive(Default)]
35struct Diagnostics {
36 dropped_lines: AtomicUsize,
37 dropped_bytes: AtomicUsize,
38 write_failures: AtomicUsize,
39}
40
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub struct AsyncLineWriterDiagnostics {
44 pub dropped_lines: usize,
45 pub dropped_bytes: usize,
46 pub write_failures: usize,
47}
48
49#[derive(Clone)]
54pub struct AsyncLineWriter {
55 sender: mpsc::Sender<LogMessage>,
56 reserved_bytes: Arc<AtomicUsize>,
57 diagnostics: Arc<Diagnostics>,
58 max_buffer_bytes: usize,
59}
60
61impl AsyncLineWriter {
62 pub fn new(path: PathBuf) -> Result<Self> {
68 Self::new_with_limits(
69 path,
70 defaults::DEFAULT_TRAJECTORY_LOG_CHANNEL_CAPACITY,
71 defaults::DEFAULT_TRAJECTORY_LOG_BUFFER_BYTES,
72 Duration::from_millis(defaults::DEFAULT_TRAJECTORY_LOG_FLUSH_INTERVAL_MS),
73 )
74 }
75
76 pub async fn new_async(path: PathBuf) -> Result<Self> {
79 Self::new_async_with_limits(
80 path,
81 defaults::DEFAULT_TRAJECTORY_LOG_CHANNEL_CAPACITY,
82 defaults::DEFAULT_TRAJECTORY_LOG_BUFFER_BYTES,
83 Duration::from_millis(defaults::DEFAULT_TRAJECTORY_LOG_FLUSH_INTERVAL_MS),
84 )
85 .await
86 }
87
88 fn new_with_limits(
89 path: PathBuf,
90 channel_capacity: usize,
91 buffer_bytes: usize,
92 flush_interval: Duration,
93 ) -> Result<Self> {
94 let runtime = tokio::runtime::Handle::try_current()
95 .context("AsyncLineWriter::new requires an active Tokio runtime; use new_async or call within one")?;
96 if let Some(parent) = path.parent() {
97 ensure_dir_exists_sync(parent)
98 .with_context(|| format!("Failed to create log directory: {}", parent.display()))?;
99 }
100
101 OpenOptions::new()
103 .create(true)
104 .append(true)
105 .open(&path)
106 .with_context(|| format!("Failed to create log file: {}", path.display()))?;
107
108 Ok(Self::spawn_actor(&runtime, path, channel_capacity, buffer_bytes, flush_interval))
109 }
110
111 async fn new_async_with_limits(
112 path: PathBuf,
113 channel_capacity: usize,
114 buffer_bytes: usize,
115 flush_interval: Duration,
116 ) -> Result<Self> {
117 if let Some(parent) = path.parent() {
118 tokio::fs::create_dir_all(parent)
119 .await
120 .with_context(|| format!("Failed to create log directory: {}", parent.display()))?;
121 }
122
123 tokio::fs::OpenOptions::new()
125 .create(true)
126 .append(true)
127 .open(&path)
128 .await
129 .with_context(|| format!("Failed to create log file: {}", path.display()))?;
130
131 let runtime = tokio::runtime::Handle::current();
132 Ok(Self::spawn_actor(&runtime, path, channel_capacity, buffer_bytes, flush_interval))
133 }
134
135 fn spawn_actor(
136 runtime: &tokio::runtime::Handle,
137 path: PathBuf,
138 channel_capacity: usize,
139 buffer_bytes: usize,
140 flush_interval: Duration,
141 ) -> Self {
142 let (sender, receiver) = mpsc::channel(channel_capacity.max(1));
143 let reserved_bytes = Arc::new(AtomicUsize::new(0));
144 let diagnostics = Arc::new(Diagnostics::default());
145
146 let actor_reserved_bytes = Arc::clone(&reserved_bytes);
148 let actor_diagnostics = Arc::clone(&diagnostics);
149 runtime.spawn(async move {
150 actor_task(
151 &path,
152 receiver,
153 actor_reserved_bytes,
154 actor_diagnostics,
155 channel_capacity.max(1),
156 buffer_bytes,
157 flush_interval,
158 )
159 .await;
160 });
161
162 Self {
163 sender,
164 reserved_bytes,
165 diagnostics,
166 max_buffer_bytes: buffer_bytes.max(1),
167 }
168 }
169
170 pub fn write_line(&self, line: String) {
172 let bytes = line.len().saturating_add(1);
173 if !reserve_bytes(&self.reserved_bytes, bytes, self.max_buffer_bytes) {
174 self.record_drop(bytes);
175 return;
176 }
177
178 if self.sender.try_send(LogMessage::Line { line, bytes }).is_err() {
179 self.reserved_bytes.fetch_sub(bytes, Ordering::AcqRel);
180 self.record_drop(bytes);
181 }
182 }
183
184 pub async fn flush(&self) {
186 let _ = self.flush_result().await;
187 }
188
189 pub async fn flush_result(&self) -> std::io::Result<()> {
195 let (tx, rx) = oneshot::channel();
196 self.sender
197 .send(LogMessage::Flush(tx))
198 .await
199 .map_err(|_error| std::io::Error::other("trajectory writer actor closed"))?;
200 match rx
201 .await
202 .map_err(|_error| std::io::Error::other("trajectory writer actor dropped flush acknowledgement"))?
203 {
204 true => Ok(()),
205 false => Err(std::io::Error::other("trajectory writer failed to flush a batch")),
206 }
207 }
208
209 pub fn diagnostics(&self) -> AsyncLineWriterDiagnostics {
210 AsyncLineWriterDiagnostics {
211 dropped_lines: self.diagnostics.dropped_lines.load(Ordering::Relaxed),
212 dropped_bytes: self.diagnostics.dropped_bytes.load(Ordering::Relaxed),
213 write_failures: self.diagnostics.write_failures.load(Ordering::Relaxed),
214 }
215 }
216
217 fn record_drop(&self, bytes: usize) {
218 self.diagnostics.dropped_lines.fetch_add(1, Ordering::Relaxed);
219 self.diagnostics.dropped_bytes.fetch_add(bytes, Ordering::Relaxed);
220 tracing::debug!(bytes, "dropping trajectory line because the writer is saturated");
221 }
222}
223
224fn reserve_bytes(reserved_bytes: &AtomicUsize, bytes: usize, max_bytes: usize) -> bool {
225 reserved_bytes
226 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
227 current.checked_add(bytes).filter(|next| *next <= max_bytes)
228 })
229 .is_ok()
230}
231
232async fn actor_task(
237 path: &Path,
238 mut receiver: mpsc::Receiver<LogMessage>,
239 reserved_bytes: Arc<AtomicUsize>,
240 diagnostics: Arc<Diagnostics>,
241 buffer_lines: usize,
242 buffer_bytes: usize,
243 flush_interval: Duration,
244) {
245 let mut buffer: Vec<(String, usize)> = Vec::new();
247 let mut buffered_bytes = 0usize;
248 let mut interval = time::interval(flush_interval.max(Duration::from_millis(1)));
249 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
250
251 loop {
252 tokio::select! {
253 msg = receiver.recv() => match msg {
254 Some(LogMessage::Line { line, bytes }) => {
255 buffered_bytes = buffered_bytes.saturating_add(bytes);
256 buffer.push((line, bytes));
257 if buffer.len() >= buffer_lines
258 || buffered_bytes >= buffer_bytes
259 {
260 let _ = flush_buffer(path, &mut buffer, &mut buffered_bytes, &reserved_bytes, &diagnostics).await;
261 }
262 }
263 Some(LogMessage::Flush(ack)) => {
264 let flushed = flush_buffer(path, &mut buffer, &mut buffered_bytes, &reserved_bytes, &diagnostics).await;
265 let _ = ack.send(flushed);
266 }
267 None => break,
268 },
269 _ = interval.tick() => {
270 let _ = flush_buffer(path, &mut buffer, &mut buffered_bytes, &reserved_bytes, &diagnostics).await;
271 },
272 }
273 }
274
275 let _ = flush_buffer(path, &mut buffer, &mut buffered_bytes, &reserved_bytes, &diagnostics).await;
277}
278
279async fn flush_buffer(
280 path: &Path,
281 buffer: &mut Vec<(String, usize)>,
282 buffered_bytes: &mut usize,
283 reserved_bytes: &AtomicUsize,
284 diagnostics: &Diagnostics,
285) -> bool {
286 if buffer.is_empty() {
287 return true;
288 }
289 let lines = std::mem::take(buffer);
290 let line_count = lines.len();
291 let bytes = *buffered_bytes;
292 *buffered_bytes = 0;
293 let written = flush_lines(path, lines).await;
294 reserved_bytes.fetch_sub(bytes, Ordering::AcqRel);
295 if !written {
296 diagnostics.dropped_lines.fetch_add(line_count, Ordering::Relaxed);
297 diagnostics.dropped_bytes.fetch_add(bytes, Ordering::Relaxed);
298 diagnostics.write_failures.fetch_add(1, Ordering::Relaxed);
299 tracing::warn!(line_count, bytes, "trajectory writer failed to flush a batch");
300 }
301 written
302}
303
304async fn flush_lines(path: &Path, lines: Vec<(String, usize)>) -> bool {
306 let path = path.to_path_buf();
307 spawn_blocking(move || {
308 let file = match OpenOptions::new().create(true).append(true).open(&path) {
309 Ok(file) => file,
310 Err(_) => return false,
311 };
312 let mut writer = BufWriter::new(file);
313 for (line, _) in &lines {
314 if writeln!(writer, "{line}").is_err() {
315 return false;
316 }
317 }
318 writer.flush().is_ok()
319 })
320 .await
321 .unwrap_or(false)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use std::fs;
328 use tempfile::TempDir;
329
330 #[tokio::test]
331 async fn drops_lines_when_byte_bound_is_saturated() {
332 let temp_dir = TempDir::new().expect("temp directory");
333 let writer =
334 AsyncLineWriter::new_with_limits(temp_dir.path().join("trajectory.jsonl"), 4, 4, Duration::from_secs(60))
335 .expect("writer");
336
337 writer.write_line("1234".to_owned());
338 writer.write_line("more data".to_owned());
339
340 let diagnostics = writer.diagnostics();
341 assert_eq!(diagnostics.dropped_lines, 2);
342 assert_eq!(diagnostics.dropped_bytes, 15);
343 }
344
345 #[test]
346 fn sync_constructor_without_runtime_errors_without_creating_file() {
347 let temp_dir = TempDir::new().expect("temp directory");
348 let path = temp_dir.path().join("nested").join("trajectory.jsonl");
349
350 let result = AsyncLineWriter::new_with_limits(path.clone(), 4, 1024, Duration::from_secs(60));
351
352 assert!(result.is_err());
353 assert!(!path.exists());
354 }
355
356 #[tokio::test]
357 async fn async_constructor_creates_parent_and_file() {
358 let temp_dir = TempDir::new().expect("temp directory");
359 let path = temp_dir.path().join("nested").join("trajectory.jsonl");
360 let writer = AsyncLineWriter::new_async(path.clone()).await.expect("writer");
361
362 assert!(path.exists());
363 writer.write_line("async setup".to_owned());
364 writer.flush().await;
365 assert_eq!(fs::read_to_string(path).expect("read log"), "async setup\n");
366 }
367
368 #[tokio::test]
369 async fn periodic_flush_writes_without_explicit_flush() {
370 let temp_dir = TempDir::new().expect("temp directory");
371 let path = temp_dir.path().join("trajectory.jsonl");
372 let writer =
373 AsyncLineWriter::new_with_limits(path.clone(), 4, 1024, Duration::from_millis(10)).expect("writer");
374
375 writer.write_line("periodic".to_owned());
376 time::sleep(Duration::from_millis(40)).await;
377
378 assert_eq!(fs::read_to_string(path).expect("read log"), "periodic\n");
379 }
380
381 #[tokio::test]
382 async fn channel_shutdown_flushes_pending_lines() {
383 let temp_dir = TempDir::new().expect("temp directory");
384 let path = temp_dir.path().join("trajectory.jsonl");
385 {
386 let writer =
387 AsyncLineWriter::new_with_limits(path.clone(), 4, 1024, Duration::from_secs(60)).expect("writer");
388 writer.write_line("final".to_owned());
389 }
390
391 for _ in 0..20 {
392 if fs::read_to_string(&path).expect("read log") == "final\n" {
393 return;
394 }
395 time::sleep(Duration::from_millis(5)).await;
396 }
397 panic!("actor did not flush before shutdown");
398 }
399
400 #[tokio::test]
401 async fn flush_result_reports_file_write_failure() {
402 let temp_dir = TempDir::new().expect("temp directory");
403 let path = temp_dir.path().join("trajectory.jsonl");
404 let writer = AsyncLineWriter::new_with_limits(path, 4, 1024, Duration::from_secs(60)).expect("writer");
405
406 fs::remove_dir_all(temp_dir.path()).expect("remove temporary directory");
407 writer.write_line("unwritable".to_owned());
408
409 assert!(writer.flush_result().await.is_err());
410 let diagnostics = writer.diagnostics();
411 assert_eq!(diagnostics.write_failures, 1);
412 assert_eq!(diagnostics.dropped_lines, 1);
413 assert_eq!(diagnostics.dropped_bytes, "unwritable\n".len());
414 }
415}