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)]
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/// Asynchronous line writer that buffers writes on a background Tokio task.
51///
52/// The writer is `Clone`; all clones share the same background task. The task
53/// exits when all handles are dropped.
54#[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    /// Create a new line writer that appends to `path`.
64    ///
65    /// Spawns a background actor task that owns the file handle and performs
66    /// all I/O via `spawn_blocking`. The file is created eagerly if it does
67    /// not exist.
68    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    /// Create a new line writer without performing synchronous setup on the
78    /// async executor.
79    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        // Create the file eagerly so callers can observe it immediately.
103        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        // Create the file eagerly so callers can observe it immediately.
125        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        // Spawn the actor task. It owns the file and runs the message loop.
148        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    /// Queue a line for writing. Drops the line if either bound is exhausted.
172    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    /// Flush pending writes and wait for completion.
186    pub async fn flush(&self) {
187        let _ = self.flush_result().await;
188    }
189
190    /// Flush pending writes and report whether the batch reached the file.
191    ///
192    /// [`Self::flush`] remains the compatibility API for best-effort callers;
193    /// this companion method lets lifecycle code and diagnostics distinguish a
194    /// successful drain from a closed actor or failed file write.
195    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
234/// Background actor task: receives messages and writes to the file.
235///
236/// Runs until the channel is closed (all senders dropped). Uses
237/// `spawn_blocking` for actual file I/O.
238async 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    // Buffer messages until we need to flush or the channel closes.
248    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    // Channel closed — flush any remaining buffered lines.
278    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
306/// Flush buffered lines to the file using `spawn_blocking`.
307async 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}