Skip to main content

appcore_ops/
observation_file.rs

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