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