1use crate::{ObservationEvent, ObservationSink, SharedObservationEvent};
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;
23use std::time::Duration;
24
25const OBSERVATION_THREAD_STACK_BYTES: usize = 512 * 1024;
26
27pub const OBSERVATION_FILE_FORMAT_V1: &str = "# appcore-observations-v1";
29pub const MAX_FILE_OBSERVATION_QUEUE_BYTES: u64 = 8 * 1024 * 1024;
31pub const MAX_FILE_OBSERVATION_QUEUE_ITEMS: usize = 65_536;
33pub const FILE_OBSERVATION_FLUSH_TIMEOUT: Duration = Duration::from_secs(30);
35const MAX_FILE_OBSERVATION_RECORD_BYTES: u64 = 256 * 1024;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct FileObservationSinkConfig {
40 pub path: PathBuf,
42 pub max_file_bytes: u64,
44 pub retained_files: usize,
46 pub queue_capacity: usize,
48 pub sync_every_records: usize,
50}
51
52impl FileObservationSinkConfig {
53 pub fn new(path: impl Into<PathBuf>) -> Self {
55 Self {
56 path: path.into(),
57 max_file_bytes: 16 * 1024 * 1024,
58 retained_files: 4,
59 queue_capacity: 4_096,
60 sync_every_records: 64,
61 }
62 }
63
64 fn validate(&self) -> std::io::Result<()> {
65 if self.max_file_bytes < 64 * 1024
66 || self.retained_files == 0
67 || self.queue_capacity == 0
68 || self.queue_capacity > MAX_FILE_OBSERVATION_QUEUE_ITEMS
69 || self.sync_every_records == 0
70 {
71 return Err(std::io::Error::new(
72 std::io::ErrorKind::InvalidInput,
73 "observation drain limits must be positive, max_file_bytes >= 64 KiB and queue_capacity <= 65536",
74 ));
75 }
76 Ok(())
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct FileObservationSinkStats {
83 pub written: u64,
85 pub dropped: u64,
87 pub errors: u64,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct FileObservationSinkPressure {
94 pub queued_bytes: u64,
96 pub max_queue_bytes: u64,
98 pub peak_queued_bytes: u64,
100 pub byte_rejections: u64,
102}
103
104enum DrainCommand {
105 Event(QueuedEvent),
106 Flush(mpsc::Sender<()>),
107}
108
109struct QueuedEvent {
110 event: SharedObservationEvent,
111 line_bytes: u64,
112 _reservation: QueueReservation,
113}
114
115struct QueueReservation {
116 budget: Arc<QueueBudget>,
117 bytes: u64,
118}
119
120impl Drop for QueueReservation {
121 fn drop(&mut self) {
122 self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel);
123 }
124}
125
126struct QueueBudget {
127 max_bytes: u64,
128 used: AtomicU64,
129 peak: AtomicU64,
130 rejected: AtomicU64,
131}
132
133impl QueueBudget {
134 const fn new(max_bytes: u64) -> Self {
135 Self {
136 max_bytes,
137 used: AtomicU64::new(0),
138 peak: AtomicU64::new(0),
139 rejected: AtomicU64::new(0),
140 }
141 }
142
143 fn reserve(self: &Arc<Self>, bytes: u64) -> Option<QueueReservation> {
144 let mut used = self.used.load(Ordering::Acquire);
145 loop {
146 let Some(next) = used.checked_add(bytes) else {
147 self.rejected.fetch_add(1, Ordering::Relaxed);
148 return None;
149 };
150 if next > self.max_bytes {
151 self.rejected.fetch_add(1, Ordering::Relaxed);
152 return None;
153 }
154 match self
155 .used
156 .compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire)
157 {
158 Ok(_) => {
159 self.peak.fetch_max(next, Ordering::Relaxed);
160 return Some(QueueReservation {
161 budget: Arc::clone(self),
162 bytes,
163 });
164 }
165 Err(current) => used = current,
166 }
167 }
168 }
169
170 fn pressure(&self) -> FileObservationSinkPressure {
171 FileObservationSinkPressure {
172 queued_bytes: self.used.load(Ordering::Acquire),
173 max_queue_bytes: self.max_bytes,
174 peak_queued_bytes: self.peak.load(Ordering::Relaxed),
175 byte_rejections: self.rejected.load(Ordering::Relaxed),
176 }
177 }
178}
179
180struct FileObservationSinkInner {
181 sender: Mutex<Option<SyncSender<DrainCommand>>>,
182 worker: Mutex<Option<JoinHandle<()>>>,
183 written: Arc<AtomicU64>,
184 dropped: AtomicU64,
185 errors: Arc<AtomicU64>,
186 queue_budget: Arc<QueueBudget>,
187 max_file_bytes: u64,
188}
189
190impl Drop for FileObservationSinkInner {
191 fn drop(&mut self) {
192 self.sender.get_mut().take();
193 if let Some(worker) = self.worker.get_mut().take() {
194 let _ = worker.join();
195 }
196 }
197}
198
199#[derive(Clone)]
201pub struct FileObservationSink {
202 inner: Arc<FileObservationSinkInner>,
203}
204
205impl std::fmt::Debug for FileObservationSink {
206 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 formatter
208 .debug_struct("FileObservationSink")
209 .field("stats", &self.stats())
210 .field("pressure", &self.pressure())
211 .finish()
212 }
213}
214
215impl FileObservationSink {
216 pub fn new(config: FileObservationSinkConfig) -> std::io::Result<Self> {
218 config.validate()?;
219 initialize_file(&config.path)?;
220 let (sender, receiver) = mpsc::sync_channel(config.queue_capacity);
221 let written = Arc::new(AtomicU64::new(0));
222 let errors = Arc::new(AtomicU64::new(0));
223 let worker_written = Arc::clone(&written);
224 let worker_errors = Arc::clone(&errors);
225 let queue_budget = Arc::new(QueueBudget::new(MAX_FILE_OBSERVATION_QUEUE_BYTES));
226 let max_file_bytes = config.max_file_bytes;
227 let worker = std::thread::Builder::new()
228 .name("appcore-observation-drain".to_string())
229 .stack_size(OBSERVATION_THREAD_STACK_BYTES)
230 .spawn(move || run_worker(config, receiver, worker_written, worker_errors))?;
231 Ok(Self {
232 inner: Arc::new(FileObservationSinkInner {
233 sender: Mutex::new(Some(sender)),
234 worker: Mutex::new(Some(worker)),
235 written,
236 dropped: AtomicU64::new(0),
237 errors,
238 queue_budget,
239 max_file_bytes,
240 }),
241 })
242 }
243
244 pub fn flush(&self) -> std::io::Result<()> {
246 self.flush_timeout(FILE_OBSERVATION_FLUSH_TIMEOUT)
247 }
248
249 pub fn flush_timeout(&self, timeout: Duration) -> std::io::Result<()> {
252 let deadline = crate::observation_flush::deadline(timeout)?;
253 let (acknowledge, receiver) = mpsc::channel();
254 let sender = self.inner.sender.lock().clone().ok_or_else(|| {
255 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "observation drain stopped")
256 })?;
257 crate::observation_flush::enqueue(&sender, DrainCommand::Flush(acknowledge), deadline)?;
258 crate::observation_flush::wait(receiver, deadline)
259 }
260
261 pub fn stats(&self) -> FileObservationSinkStats {
263 FileObservationSinkStats {
264 written: self.inner.written.load(Ordering::Relaxed),
265 dropped: self.inner.dropped.load(Ordering::Relaxed),
266 errors: self.inner.errors.load(Ordering::Relaxed),
267 }
268 }
269
270 pub fn pressure(&self) -> FileObservationSinkPressure {
272 self.inner.queue_budget.pressure()
273 }
274
275 fn enqueue_shared(&self, event: SharedObservationEvent) {
276 let line_bytes = match measure_event_line(self.inner.max_file_bytes, event.as_event()) {
277 Ok(bytes) => bytes,
278 Err(_) => {
279 self.inner.errors.fetch_add(1, Ordering::Relaxed);
280 return;
281 }
282 };
283 let Some(reservation) = self.inner.queue_budget.reserve(line_bytes) else {
284 self.inner.dropped.fetch_add(1, Ordering::Relaxed);
285 return;
286 };
287 let event = QueuedEvent {
288 event,
289 line_bytes,
290 _reservation: reservation,
291 };
292 let Some(sender) = self.inner.sender.lock().clone() else {
293 self.inner.dropped.fetch_add(1, Ordering::Relaxed);
294 return;
295 };
296 match sender.try_send(DrainCommand::Event(event)) {
297 Ok(()) => {}
298 Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
299 self.inner.dropped.fetch_add(1, Ordering::Relaxed);
300 }
301 }
302 }
303}
304
305impl ObservationSink for FileObservationSink {
306 fn emit(&self, event: ObservationEvent) {
307 self.enqueue_shared(SharedObservationEvent::new(event));
308 }
309
310 fn emit_shared(&self, event: &SharedObservationEvent) {
311 self.enqueue_shared(event.clone());
312 }
313}
314
315fn run_worker(
316 config: FileObservationSinkConfig,
317 receiver: Receiver<DrainCommand>,
318 written: Arc<AtomicU64>,
319 errors: Arc<AtomicU64>,
320) {
321 let mut unsynced = 0usize;
322 let mut file = open_append(&config.path).ok();
323 while let Ok(command) = receiver.recv() {
324 match command {
325 DrainCommand::Event(event) => {
326 let result =
327 write_event(&config, &mut file, event.event.as_event(), event.line_bytes);
328 if result.is_ok() {
329 written.fetch_add(1, Ordering::Relaxed);
330 unsynced += 1;
331 } else {
332 errors.fetch_add(1, Ordering::Relaxed);
333 }
334 if unsynced >= config.sync_every_records {
335 sync_file(&mut file, &errors);
336 unsynced = 0;
337 }
338 }
339 DrainCommand::Flush(acknowledge) => {
340 sync_file(&mut file, &errors);
341 unsynced = 0;
342 let _ = acknowledge.send(());
343 }
344 }
345 }
346 sync_file(&mut file, &errors);
347}
348
349fn write_event(
350 config: &FileObservationSinkConfig,
351 file: &mut Option<File>,
352 event: &ObservationEvent,
353 line_bytes: u64,
354) -> std::io::Result<()> {
355 let current_size = file
356 .as_ref()
357 .and_then(|file| file.metadata().ok())
358 .map(|metadata| metadata.len())
359 .unwrap_or(0);
360 if current_size
361 .checked_add(line_bytes)
362 .is_none_or(|bytes| bytes > config.max_file_bytes)
363 {
364 if let Some(active) = file.take() {
365 active.sync_all()?;
366 }
367 rotate_files(config)?;
368 *file = Some(open_append(&config.path)?);
369 }
370 if file.is_none() {
371 *file = Some(open_append(&config.path)?);
372 }
373 match file.as_mut() {
374 Some(file) => write_event_line(file, event),
375 None => Err(std::io::Error::other(
376 "observation file was not initialized",
377 )),
378 }
379}
380
381fn measure_event_line(max_file_bytes: u64, event: &ObservationEvent) -> std::io::Result<u64> {
382 let header_bytes = u64::try_from(OBSERVATION_FILE_FORMAT_V1.len())
383 .ok()
384 .and_then(|bytes| bytes.checked_add(1))
385 .ok_or_else(|| std::io::Error::other("observation size overflow"))?;
386 let file_json_limit = max_file_bytes
387 .checked_sub(header_bytes)
388 .and_then(|bytes| bytes.checked_sub(1))
389 .ok_or_else(record_too_large)?;
390 let record_json_limit = MAX_FILE_OBSERVATION_RECORD_BYTES
391 .checked_sub(1)
392 .ok_or_else(record_too_large)?;
393 let json_limit = file_json_limit.min(record_json_limit);
394 let mut counter = LimitedCounter::new(json_limit);
395 let result = serde_json::to_writer(&mut counter, &VersionedObservation::new(event));
396 if counter.exceeded {
397 return Err(record_too_large());
398 }
399 result.map_err(std::io::Error::other)?;
400 counter
401 .bytes
402 .checked_add(1)
403 .ok_or_else(|| std::io::Error::other("observation size overflow"))
404}
405
406fn write_event_line(writer: &mut impl Write, event: &ObservationEvent) -> std::io::Result<()> {
407 serde_json::to_writer(&mut *writer, &VersionedObservation::new(event))
408 .map_err(std::io::Error::other)?;
409 writer.write_all(b"\n")
410}
411
412fn record_too_large() -> std::io::Error {
413 std::io::Error::new(
414 std::io::ErrorKind::InvalidData,
415 "observation record exceeds file limit",
416 )
417}
418
419#[derive(Serialize)]
420struct VersionedObservation<'a> {
421 schema: &'static str,
422 event: &'a ObservationEvent,
423}
424
425impl<'a> VersionedObservation<'a> {
426 fn new(event: &'a ObservationEvent) -> Self {
427 Self {
428 schema: "appcore.observation.v1",
429 event,
430 }
431 }
432}
433
434struct LimitedCounter {
435 bytes: u64,
436 limit: u64,
437 exceeded: bool,
438}
439
440impl LimitedCounter {
441 const fn new(limit: u64) -> Self {
442 Self {
443 bytes: 0,
444 limit,
445 exceeded: false,
446 }
447 }
448}
449
450impl Write for LimitedCounter {
451 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
452 let Some(bytes) = self.bytes.checked_add(buffer.len() as u64) else {
453 self.exceeded = true;
454 return Err(std::io::Error::other("observation size overflow"));
455 };
456 if bytes > self.limit {
457 self.exceeded = true;
458 return Err(record_too_large());
459 }
460 self.bytes = bytes;
461 Ok(buffer.len())
462 }
463
464 fn flush(&mut self) -> std::io::Result<()> {
465 Ok(())
466 }
467}
468
469fn initialize_file(path: &Path) -> std::io::Result<()> {
470 let parent = path.parent().unwrap_or_else(|| Path::new("."));
471 fs::create_dir_all(parent)?;
472 reject_symlink(path)?;
473 if !path.exists() {
474 let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
475 writeln!(file, "{OBSERVATION_FILE_FORMAT_V1}")?;
476 file.sync_all()?;
477 sync_parent(parent)?;
478 return Ok(());
479 }
480 let mut first = String::new();
481 BufReader::new(File::open(path)?).read_line(&mut first)?;
482 if first.trim_end() != OBSERVATION_FILE_FORMAT_V1 {
483 return Err(std::io::Error::new(
484 std::io::ErrorKind::InvalidData,
485 "unsupported observation file format",
486 ));
487 }
488 Ok(())
489}
490
491fn open_append(path: &Path) -> std::io::Result<File> {
492 initialize_file(path)?;
493 OpenOptions::new().append(true).read(true).open(path)
494}
495
496fn rotate_files(config: &FileObservationSinkConfig) -> std::io::Result<()> {
497 for index in (1..=config.retained_files).rev() {
498 let source = rotated_path(&config.path, index);
499 if index == config.retained_files {
500 remove_if_exists(&source)?;
501 } else if source.exists() {
502 fs::rename(&source, rotated_path(&config.path, index + 1))?;
503 }
504 }
505 if config.path.exists() {
506 fs::rename(&config.path, rotated_path(&config.path, 1))?;
507 }
508 initialize_file(&config.path)
509}
510
511fn rotated_path(path: &Path, index: usize) -> PathBuf {
512 let name = path
513 .file_name()
514 .and_then(|name| name.to_str())
515 .unwrap_or("observations.jsonl");
516 path.with_file_name(format!("{name}.{index}"))
517}
518
519fn reject_symlink(path: &Path) -> std::io::Result<()> {
520 match fs::symlink_metadata(path) {
521 Ok(metadata) if metadata.file_type().is_symlink() => Err(std::io::Error::new(
522 std::io::ErrorKind::InvalidInput,
523 "observation path must not be a symlink",
524 )),
525 Ok(metadata) if !metadata.is_file() => Err(std::io::Error::new(
526 std::io::ErrorKind::InvalidInput,
527 "observation path must be a regular file",
528 )),
529 Ok(_) => Ok(()),
530 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
531 Err(error) => Err(error),
532 }
533}
534
535fn sync_file(file: &mut Option<File>, errors: &AtomicU64) {
536 if file.as_ref().is_some_and(|file| file.sync_all().is_err()) {
537 errors.fetch_add(1, Ordering::Relaxed);
538 }
539}
540
541fn remove_if_exists(path: &Path) -> std::io::Result<()> {
542 match fs::remove_file(path) {
543 Ok(()) => Ok(()),
544 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
545 Err(error) => Err(error),
546 }
547}
548
549#[cfg(unix)]
550fn sync_parent(path: &Path) -> std::io::Result<()> {
551 File::open(path)?.sync_all()
552}
553
554#[cfg(not(unix))]
555fn sync_parent(_path: &Path) -> std::io::Result<()> {
556 Ok(())
557}
558
559#[cfg(test)]
560#[path = "observation_file_tests.rs"]
561mod tests;