Skip to main content

vtcode_core/utils/
async_line_writer.rs

1//! Async line writer using a Tokio-based actor pattern.
2//!
3//! The actor owns a background task that performs file I/O via `spawn_blocking`.
4//! The handle (`AsyncLineWriter`) sends messages through a bounded `mpsc` channel,
5//! so the caller never blocks on I/O. When the handle is dropped, the channel closes
6//! and the background task finishes its current write before exiting.
7//!
8//! This follows the "Actors with Tokio" pattern:
9//! - Handle (`AsyncLineWriter`) is separate from the actor task.
10//! - Handle is `Clone` (via `mpsc::Sender`).
11//! - Graceful shutdown when all senders are dropped.
12//! - Bounded channel for backpressure.
13
14use 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/// Snapshot of best-effort exporter writer health.
42#[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/// Asynchronous line writer that buffers writes on a background Tokio task.
50///
51/// The writer is `Clone`; all clones share the same background task. The task
52/// exits when all handles are dropped.
53#[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    /// Create a new line writer that appends to `path`.
63    ///
64    /// Spawns a background actor task that owns the file handle and performs
65    /// all I/O via `spawn_blocking`. The file is created eagerly if it does
66    /// not exist.
67    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    /// Create a new line writer without performing synchronous setup on the
77    /// async executor.
78    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        // Create the file eagerly so callers can observe it immediately.
102        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        // Create the file eagerly so callers can observe it immediately.
124        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        // Spawn the actor task. It owns the file and runs the message loop.
147        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    /// Queue a line for writing. Drops the line if either bound is exhausted.
171    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    /// Flush pending writes and wait for completion.
185    pub async fn flush(&self) {
186        let _ = self.flush_result().await;
187    }
188
189    /// Flush pending writes and report whether the batch reached the file.
190    ///
191    /// [`Self::flush`] remains the compatibility API for best-effort callers;
192    /// this companion method lets lifecycle code and diagnostics distinguish a
193    /// successful drain from a closed actor or failed file write.
194    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
232/// Background actor task: receives messages and writes to the file.
233///
234/// Runs until the channel is closed (all senders dropped). Uses
235/// `spawn_blocking` for actual file I/O.
236async 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    // Buffer messages until we need to flush or the channel closes.
246    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    // Channel closed — flush any remaining buffered lines.
276    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
304/// Flush buffered lines to the file using `spawn_blocking`.
305async 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}