Skip to main content

appcore_ops/
observation_file.rs

1//! Bounded asynchronous JSONL drain for local production operations.
2
3use crate::{ObservationEvent, ObservationSink};
4use parking_lot::Mutex;
5use serde::Serialize;
6use std::fs::{self, File, OpenOptions};
7use std::io::{BufRead, BufReader, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
11use std::sync::Arc;
12use std::thread::JoinHandle;
13
14/// Stable marker written as the first line of every observation JSONL file.
15pub const OBSERVATION_FILE_FORMAT_V1: &str = "# appcore-observations-v1";
16
17/// Local observation drain limits and retention policy.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct FileObservationSinkConfig {
20    /// Active JSONL file path.
21    pub path: PathBuf,
22    /// Maximum active file size before rotation.
23    pub max_file_bytes: u64,
24    /// Number of rotated files retained.
25    pub retained_files: usize,
26    /// Maximum queued observations awaiting disk.
27    pub queue_capacity: usize,
28    /// Number of records written between `fsync` calls.
29    pub sync_every_records: usize,
30}
31
32impl FileObservationSinkConfig {
33    /// Creates a production-safe bounded configuration.
34    pub fn new(path: impl Into<PathBuf>) -> Self {
35        Self {
36            path: path.into(),
37            max_file_bytes: 16 * 1024 * 1024,
38            retained_files: 4,
39            queue_capacity: 4_096,
40            sync_every_records: 64,
41        }
42    }
43
44    fn validate(&self) -> std::io::Result<()> {
45        if self.max_file_bytes < 64 * 1024
46            || self.retained_files == 0
47            || self.queue_capacity == 0
48            || self.sync_every_records == 0
49        {
50            return Err(std::io::Error::new(
51                std::io::ErrorKind::InvalidInput,
52                "observation drain limits must be positive and max_file_bytes >= 64 KiB",
53            ));
54        }
55        Ok(())
56    }
57}
58
59/// Point-in-time counters for one file observation drain.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct FileObservationSinkStats {
62    /// Records durably accepted by the worker.
63    pub written: u64,
64    /// Records discarded because the bounded queue was full.
65    pub dropped: u64,
66    /// Filesystem or serialization failures.
67    pub errors: u64,
68}
69
70enum DrainCommand {
71    Event(ObservationEvent),
72    Flush(mpsc::Sender<()>),
73}
74
75struct FileObservationSinkInner {
76    sender: Mutex<Option<SyncSender<DrainCommand>>>,
77    worker: Mutex<Option<JoinHandle<()>>>,
78    written: Arc<AtomicU64>,
79    dropped: AtomicU64,
80    errors: Arc<AtomicU64>,
81}
82
83impl Drop for FileObservationSinkInner {
84    fn drop(&mut self) {
85        self.sender.get_mut().take();
86        if let Some(worker) = self.worker.get_mut().take() {
87            let _ = worker.join();
88        }
89    }
90}
91
92/// Cloneable non-blocking observation sink backed by a bounded worker queue.
93#[derive(Clone)]
94pub struct FileObservationSink {
95    inner: Arc<FileObservationSinkInner>,
96}
97
98impl std::fmt::Debug for FileObservationSink {
99    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        formatter
101            .debug_struct("FileObservationSink")
102            .field("stats", &self.stats())
103            .finish()
104    }
105}
106
107impl FileObservationSink {
108    /// Creates the active file and starts the bounded writer worker.
109    pub fn new(config: FileObservationSinkConfig) -> std::io::Result<Self> {
110        config.validate()?;
111        initialize_file(&config.path)?;
112        let (sender, receiver) = mpsc::sync_channel(config.queue_capacity);
113        let written = Arc::new(AtomicU64::new(0));
114        let errors = Arc::new(AtomicU64::new(0));
115        let worker_written = Arc::clone(&written);
116        let worker_errors = Arc::clone(&errors);
117        let worker = std::thread::Builder::new()
118            .name("appcore-observation-drain".to_string())
119            .spawn(move || run_worker(config, receiver, worker_written, worker_errors))?;
120        Ok(Self {
121            inner: Arc::new(FileObservationSinkInner {
122                sender: Mutex::new(Some(sender)),
123                worker: Mutex::new(Some(worker)),
124                written,
125                dropped: AtomicU64::new(0),
126                errors,
127            }),
128        })
129    }
130
131    /// Flushes all events accepted before this call.
132    pub fn flush(&self) -> std::io::Result<()> {
133        let (acknowledge, receiver) = mpsc::channel();
134        let sender = self.inner.sender.lock().clone().ok_or_else(|| {
135            std::io::Error::new(std::io::ErrorKind::BrokenPipe, "observation drain stopped")
136        })?;
137        sender
138            .send(DrainCommand::Flush(acknowledge))
139            .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "drain stopped"))?;
140        receiver
141            .recv()
142            .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "drain stopped"))
143    }
144
145    /// Returns worker, backpressure and failure counters.
146    pub fn stats(&self) -> FileObservationSinkStats {
147        FileObservationSinkStats {
148            written: self.inner.written.load(Ordering::Relaxed),
149            dropped: self.inner.dropped.load(Ordering::Relaxed),
150            errors: self.inner.errors.load(Ordering::Relaxed),
151        }
152    }
153}
154
155impl ObservationSink for FileObservationSink {
156    fn emit(&self, event: ObservationEvent) {
157        let Some(sender) = self.inner.sender.lock().clone() else {
158            self.inner.dropped.fetch_add(1, Ordering::Relaxed);
159            return;
160        };
161        match sender.try_send(DrainCommand::Event(event.redacted())) {
162            Ok(()) => {}
163            Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
164                self.inner.dropped.fetch_add(1, Ordering::Relaxed);
165            }
166        }
167    }
168}
169
170fn run_worker(
171    config: FileObservationSinkConfig,
172    receiver: Receiver<DrainCommand>,
173    written: Arc<AtomicU64>,
174    errors: Arc<AtomicU64>,
175) {
176    let mut unsynced = 0usize;
177    let mut file = open_append(&config.path).ok();
178    while let Ok(command) = receiver.recv() {
179        match command {
180            DrainCommand::Event(event) => {
181                let result = write_event(&config, &mut file, &event);
182                if result.is_ok() {
183                    written.fetch_add(1, Ordering::Relaxed);
184                    unsynced += 1;
185                } else {
186                    errors.fetch_add(1, Ordering::Relaxed);
187                }
188                if unsynced >= config.sync_every_records {
189                    sync_file(&mut file, &errors);
190                    unsynced = 0;
191                }
192            }
193            DrainCommand::Flush(acknowledge) => {
194                sync_file(&mut file, &errors);
195                unsynced = 0;
196                let _ = acknowledge.send(());
197            }
198        }
199    }
200    sync_file(&mut file, &errors);
201}
202
203fn write_event(
204    config: &FileObservationSinkConfig,
205    file: &mut Option<File>,
206    event: &ObservationEvent,
207) -> std::io::Result<()> {
208    let mut line = serde_json::to_vec(&VersionedObservation::new(event))?;
209    line.push(b'\n');
210    let current_size = file
211        .as_ref()
212        .and_then(|file| file.metadata().ok())
213        .map(|metadata| metadata.len())
214        .unwrap_or(0);
215    if current_size.saturating_add(line.len() as u64) > config.max_file_bytes {
216        if let Some(active) = file.take() {
217            active.sync_all()?;
218        }
219        rotate_files(config)?;
220        *file = Some(open_append(&config.path)?);
221    }
222    if file.is_none() {
223        *file = Some(open_append(&config.path)?);
224    }
225    match file.as_mut() {
226        Some(file) => file.write_all(&line),
227        None => Err(std::io::Error::other(
228            "observation file was not initialized",
229        )),
230    }
231}
232
233#[derive(Serialize)]
234struct VersionedObservation<'a> {
235    schema: &'static str,
236    event: &'a ObservationEvent,
237}
238
239impl<'a> VersionedObservation<'a> {
240    fn new(event: &'a ObservationEvent) -> Self {
241        Self {
242            schema: "appcore.observation.v1",
243            event,
244        }
245    }
246}
247
248fn initialize_file(path: &Path) -> std::io::Result<()> {
249    let parent = path.parent().unwrap_or_else(|| Path::new("."));
250    fs::create_dir_all(parent)?;
251    reject_symlink(path)?;
252    if !path.exists() {
253        let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
254        writeln!(file, "{OBSERVATION_FILE_FORMAT_V1}")?;
255        file.sync_all()?;
256        sync_parent(parent)?;
257        return Ok(());
258    }
259    let mut first = String::new();
260    BufReader::new(File::open(path)?).read_line(&mut first)?;
261    if first.trim_end() != OBSERVATION_FILE_FORMAT_V1 {
262        return Err(std::io::Error::new(
263            std::io::ErrorKind::InvalidData,
264            "unsupported observation file format",
265        ));
266    }
267    Ok(())
268}
269
270fn open_append(path: &Path) -> std::io::Result<File> {
271    initialize_file(path)?;
272    OpenOptions::new().append(true).read(true).open(path)
273}
274
275fn rotate_files(config: &FileObservationSinkConfig) -> std::io::Result<()> {
276    for index in (1..=config.retained_files).rev() {
277        let source = rotated_path(&config.path, index);
278        if index == config.retained_files {
279            remove_if_exists(&source)?;
280        } else if source.exists() {
281            fs::rename(&source, rotated_path(&config.path, index + 1))?;
282        }
283    }
284    if config.path.exists() {
285        fs::rename(&config.path, rotated_path(&config.path, 1))?;
286    }
287    initialize_file(&config.path)
288}
289
290fn rotated_path(path: &Path, index: usize) -> PathBuf {
291    let name = path
292        .file_name()
293        .and_then(|name| name.to_str())
294        .unwrap_or("observations.jsonl");
295    path.with_file_name(format!("{name}.{index}"))
296}
297
298fn reject_symlink(path: &Path) -> std::io::Result<()> {
299    match fs::symlink_metadata(path) {
300        Ok(metadata) if metadata.file_type().is_symlink() => Err(std::io::Error::new(
301            std::io::ErrorKind::InvalidInput,
302            "observation path must not be a symlink",
303        )),
304        Ok(metadata) if !metadata.is_file() => Err(std::io::Error::new(
305            std::io::ErrorKind::InvalidInput,
306            "observation path must be a regular file",
307        )),
308        Ok(_) => Ok(()),
309        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
310        Err(error) => Err(error),
311    }
312}
313
314fn sync_file(file: &mut Option<File>, errors: &AtomicU64) {
315    if file.as_ref().is_some_and(|file| file.sync_all().is_err()) {
316        errors.fetch_add(1, Ordering::Relaxed);
317    }
318}
319
320fn remove_if_exists(path: &Path) -> std::io::Result<()> {
321    match fs::remove_file(path) {
322        Ok(()) => Ok(()),
323        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
324        Err(error) => Err(error),
325    }
326}
327
328#[cfg(unix)]
329fn sync_parent(path: &Path) -> std::io::Result<()> {
330    File::open(path)?.sync_all()
331}
332
333#[cfg(not(unix))]
334fn sync_parent(_path: &Path) -> std::io::Result<()> {
335    Ok(())
336}
337
338#[cfg(test)]
339#[path = "observation_file_tests.rs"]
340mod tests;