Skip to main content

segment_buffer/
lib.rs

1//! Durable bounded queue backed by zstd-compressed CBOR segment files.
2//!
3//! Items are accumulated in memory, flushed as zstd-compressed CBOR batches
4//! to `seg_{start:012}_{end:012}.zst` files, and deleted once the consumer
5//! acknowledges receipt via [`SegmentBuffer::delete_acked`].
6//!
7//! The buffer is generic over any `T: Serialize + DeserializeOwned + Clone + Send + 'static`.
8//! Crash recovery is filename-based: scanning the directory rebuilds `head_seq`
9//! and `next_seq` without any WAL or metadata database.
10//!
11//! # Example
12//!
13//! ```no_run
14//! use segment_buffer::{SegmentBuffer, SegmentConfig};
15//! use serde::{Serialize, Deserialize};
16//!
17//! #[derive(Serialize, Deserialize, Clone)]
18//! struct MyItem { id: u64 }
19//!
20//! let buffer = SegmentBuffer::<MyItem>::open("/tmp/my-queue", SegmentConfig::default())?;
21//! let seq = buffer.append(MyItem { id: 1 })?;
22//! let items = buffer.read_from(0, 100)?;
23//! # Ok::<(), Box<dyn std::error::Error>>(())
24//! ```
25
26#![warn(missing_docs)]
27
28mod cipher;
29mod error;
30mod segment;
31
32#[cfg(feature = "encryption")]
33pub use cipher::AesGcmCipher;
34pub use cipher::{CipherError, SegmentCipher};
35pub use error::{Result, SegmentError};
36
37use std::fs;
38use std::path::PathBuf;
39use std::time::Instant;
40
41use parking_lot::Mutex;
42use serde::de::DeserializeOwned;
43use serde::Serialize;
44use tracing::{debug, info};
45
46use segment::SegmentRange;
47
48/// Configuration knobs for [`SegmentBuffer`].
49///
50/// This struct is `#[non_exhaustive]`: new fields may be added in any release
51/// without breaking semver. Construct via [`SegmentConfig::default()`] and then
52/// mutate the public fields you care about:
53///
54/// ```
55/// use segment_buffer::SegmentConfig;
56///
57/// let mut config = SegmentConfig::default();
58/// config.max_batch_events = 64;
59/// config.flush_interval_secs = 1;
60/// ```
61#[non_exhaustive]
62pub struct SegmentConfig {
63    /// Max events accumulated in RAM before auto-flush (default: 256).
64    pub max_batch_events: usize,
65    /// Max seconds between flushes. An append after this interval triggers a
66    /// flush even if the batch threshold hasn't been reached (default: 5s).
67    pub flush_interval_secs: u64,
68    /// Max total disk usage before the buffer reports overload pressure (default: 10 GB).
69    pub max_size_bytes: u64,
70    /// zstd compression level (1-22; 3 is fast with a good ratio).
71    pub compression_level: i32,
72    /// Optional cipher for encrypting segment files at rest. When `None`,
73    /// segments are written as plaintext zstd+CBOR.
74    pub cipher: Option<Box<dyn SegmentCipher>>,
75}
76
77impl std::fmt::Debug for SegmentConfig {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("SegmentConfig")
80            .field("max_batch_events", &self.max_batch_events)
81            .field("flush_interval_secs", &self.flush_interval_secs)
82            .field("max_size_bytes", &self.max_size_bytes)
83            .field("compression_level", &self.compression_level)
84            .field("cipher", &self.cipher.as_ref().map(|_| "[set]"))
85            .finish()
86    }
87}
88
89impl Default for SegmentConfig {
90    fn default() -> Self {
91        Self {
92            max_batch_events: 256,
93            flush_interval_secs: 5,
94            max_size_bytes: 10 * 1024 * 1024 * 1024,
95            compression_level: 3,
96            cipher: None,
97        }
98    }
99}
100
101/// Point-in-time snapshot of buffer state, captured atomically under a single
102/// lock acquisition so all fields are mutually consistent.
103///
104/// Returned by [`SegmentBuffer::stats`]. Useful for metrics endpoints or
105/// dashboards that need to observe multiple values without paying for several
106/// lock/unlock round-trips (and risking a torn read between calls).
107///
108/// This struct is `#[non_exhaustive]`: new fields may be added in any release
109/// without breaking semver. It is constructed internally by [`SegmentBuffer::stats`];
110/// callers read fields via dot-syntax or pattern-match with `..` only.
111#[derive(Debug, Clone)]
112#[non_exhaustive]
113pub struct BufferStats {
114    /// Items waiting in the buffer (on-disk + in-memory pending).
115    /// Same value as [`SegmentBuffer::pending_count`].
116    pub pending_count: u64,
117    /// Highest sequence number assigned (or `0` if the buffer is empty).
118    /// Same value as [`SegmentBuffer::latest_sequence`].
119    pub latest_sequence: u64,
120    /// Oldest unacknowledged sequence number (`head_seq`).
121    pub head_sequence: u64,
122    /// Next sequence number that will be assigned by the next successful
123    /// [`SegmentBuffer::append`] (`next_seq`).
124    pub next_sequence: u64,
125    /// Approximate total bytes used by segment files on disk. Decreases when
126    /// [`SegmentBuffer::delete_acked`] removes files.
127    pub approx_disk_bytes: u64,
128    /// Configured ceiling on disk usage (`max_size_bytes`). `0` disables the
129    /// limit; in that case [`store_pressure`](Self::store_pressure) is `0.0`.
130    pub max_size_bytes: u64,
131    /// `approx_disk_bytes / max_size_bytes`, clamped to `[0.0, 1.0]`.
132    /// `0.0` when no limit is configured.
133    pub store_pressure: f32,
134}
135
136struct BufferInner<T> {
137    /// Items buffered in memory, not yet written to a segment file. Drained by
138    /// [`SegmentBuffer::flush`] and rebuilt empty on crash recovery (unflushed
139    /// items do not survive a crash by design).
140    unflushed: Vec<T>,
141    next_seq: u64,
142    head_seq: u64,
143    last_flush: Instant,
144    approx_disk_bytes: u64,
145}
146
147/// Durable bounded queue of `T` backed by compressed segment files.
148///
149/// Thread-safe via `parking_lot::Mutex`. All file I/O is synchronous. The mutex
150/// is never held across an async boundary because there are no await points.
151///
152/// Create with [`SegmentBuffer::open`], supplying the directory and config.
153pub struct SegmentBuffer<T> {
154    dir: PathBuf,
155    config: SegmentConfig,
156    inner: Mutex<BufferInner<T>>,
157}
158
159/// `Debug` mirrors the field set of [`BufferStats`] plus the directory path.
160/// It does NOT print the in-memory `unflushed` items (which could be large or
161/// sensitive), so `T` itself is not required to be `Debug`.
162impl<T> std::fmt::Debug for SegmentBuffer<T>
163where
164    T: Serialize + DeserializeOwned + Clone + Send + 'static,
165{
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        let stats = self.stats();
168        f.debug_struct("SegmentBuffer")
169            .field("dir", &self.dir)
170            .field("pending_count", &stats.pending_count)
171            .field("latest_sequence", &stats.latest_sequence)
172            .field("head_sequence", &stats.head_sequence)
173            .field("next_sequence", &stats.next_sequence)
174            .field("approx_disk_bytes", &stats.approx_disk_bytes)
175            .field("max_size_bytes", &stats.max_size_bytes)
176            .field("store_pressure", &stats.store_pressure)
177            .finish()
178    }
179}
180
181impl<T> SegmentBuffer<T>
182where
183    T: Serialize + DeserializeOwned + Clone + Send + 'static,
184{
185    /// Open (or create) a buffer at `dir`, recovering from any existing
186    /// segment files.
187    ///
188    /// Recovery is **filename-based**: it scans the directory to rebuild
189    /// `head_seq` / `next_seq` and deletes leftover `.tmp` debris. Segment
190    /// *contents* are not read until [`read_from`](Self::read_from), so a
191    /// corrupted segment does not fail here — it fails when read.
192    ///
193    /// # Example
194    ///
195    /// ```
196    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
197    /// use tempfile::tempdir;
198    ///
199    /// let dir = tempdir()?;
200    /// let buf: SegmentBuffer<u64> =
201    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
202    /// # Ok::<(), Box<dyn std::error::Error>>(())
203    /// ```
204    ///
205    /// # Errors
206    ///
207    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
208    pub fn open(dir: impl Into<PathBuf>, config: SegmentConfig) -> Result<Self> {
209        let dir = dir.into();
210        fs::create_dir_all(&dir)?;
211
212        let buffer = Self {
213            dir,
214            config,
215            inner: Mutex::new(BufferInner {
216                unflushed: Vec::new(),
217                next_seq: 0,
218                head_seq: 0,
219                last_flush: Instant::now(),
220                approx_disk_bytes: 0,
221            }),
222        };
223
224        buffer.recover()?;
225        Ok(buffer)
226    }
227
228    // -----------------------------------------------------------------------
229    // Public API
230    // -----------------------------------------------------------------------
231
232    /// Append an item to the buffer. Assigns the next sequence number and
233    /// auto-flushes if the batch threshold or interval is reached.
234    ///
235    /// Returns the assigned sequence number. The first append returns `0`,
236    /// and the number increments by 1 for each subsequent append.
237    ///
238    /// # Example
239    ///
240    /// ```
241    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
242    /// use tempfile::tempdir;
243    ///
244    /// let dir = tempdir()?;
245    /// let buf: SegmentBuffer<u64> =
246    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
247    ///
248    /// assert_eq!(buf.append(1)?, 0);
249    /// assert_eq!(buf.append(2)?, 1);
250    /// assert_eq!(buf.append(3)?, 2);
251    /// # Ok::<(), Box<dyn std::error::Error>>(())
252    /// ```
253    pub fn append(&self, event: T) -> Result<u64> {
254        let (should_flush, seq) = {
255            let mut inner = self.inner.lock();
256            inner.unflushed.push(event);
257            inner.next_seq += 1;
258            let seq = inner.next_seq - 1;
259
260            let batch_full = inner.unflushed.len() >= self.config.max_batch_events;
261            let interval_elapsed =
262                inner.last_flush.elapsed().as_secs() >= self.config.flush_interval_secs;
263            (batch_full || interval_elapsed, seq)
264        };
265
266        if should_flush {
267            self.flush()?;
268        }
269
270        Ok(seq)
271    }
272
273    /// Flush buffered items to a segment file. No-op if nothing is buffered.
274    ///
275    /// Flushing is also triggered automatically by [`append`](Self::append)
276    /// when the batch threshold (`max_batch_events`) or interval
277    /// (`flush_interval_secs`) is reached. Call this explicitly when you need
278    /// durability before a known threshold.
279    ///
280    /// # Example
281    ///
282    /// ```
283    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
284    /// use tempfile::tempdir;
285    ///
286    /// let dir = tempdir()?;
287    /// let buf: SegmentBuffer<u64> =
288    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
289    /// buf.append(1)?;
290    /// buf.append(2)?;
291    ///
292    /// buf.flush()?; // items now durable on disk
293    /// assert_eq!(buf.pending_count(), 2);
294    /// # Ok::<(), Box<dyn std::error::Error>>(())
295    /// ```
296    pub fn flush(&self) -> Result<()> {
297        let (events, start_seq, end_seq) = {
298            let mut inner = self.inner.lock();
299            inner.last_flush = Instant::now();
300            if inner.unflushed.is_empty() {
301                return Ok(());
302            }
303            let events = std::mem::take(&mut inner.unflushed);
304            let count = events.len() as u64;
305            let end_seq = inner.next_seq - 1;
306            let start_seq = end_seq + 1 - count;
307            (events, start_seq, end_seq)
308        };
309
310        let compressed_len = self.write_segment(start_seq, end_seq, &events)?;
311
312        {
313            let mut inner = self.inner.lock();
314            inner.approx_disk_bytes += compressed_len;
315        }
316
317        debug!(start_seq, end_seq, count = events.len(), "Flushed segment");
318        Ok(())
319    }
320
321    /// Read up to `limit` items starting from `start_seq` (inclusive).
322    ///
323    /// Reads from both on-disk segment files and in-memory pending items.
324    /// Items are returned in ascending sequence order.
325    ///
326    /// Passing `limit = 0` returns an empty `Vec` without scanning.
327    ///
328    /// # Example
329    ///
330    /// ```
331    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
332    /// use tempfile::tempdir;
333    ///
334    /// let dir = tempdir()?;
335    /// let buf: SegmentBuffer<u64> =
336    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
337    /// buf.append(10)?;
338    /// buf.append(20)?;
339    /// buf.append(30)?;
340    /// buf.flush()?;
341    ///
342    /// let items = buf.read_from(0, 100)?;
343    /// assert_eq!(items, vec![10, 20, 30]);
344    ///
345    /// // start_seq skips already-read items:
346    /// let tail = buf.read_from(2, 100)?;
347    /// assert_eq!(tail, vec![30]);
348    /// # Ok::<(), Box<dyn std::error::Error>>(())
349    /// ```
350    pub fn read_from(&self, start_seq: u64, limit: usize) -> Result<Vec<T>> {
351        if limit == 0 {
352            return Ok(Vec::new());
353        }
354
355        let mut result: Vec<T> = Vec::with_capacity(limit.min(1024));
356
357        // Phase 1: read from on-disk segments.
358        let segments = self.scan_segments()?;
359        for seg in &segments {
360            if result.len() >= limit {
361                break;
362            }
363            if seg.end < start_seq {
364                continue;
365            }
366
367            let events = self.read_segment(*seg)?;
368            let skip = if seg.start < start_seq {
369                (start_seq - seg.start) as usize
370            } else {
371                0
372            };
373
374            for event in events.into_iter().skip(skip) {
375                if result.len() >= limit {
376                    break;
377                }
378                result.push(event);
379            }
380        }
381
382        // Phase 2: read from in-memory pending events.
383        if result.len() < limit {
384            let inner = self.inner.lock();
385            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
386            for (i, event) in inner.unflushed.iter().enumerate() {
387                let seq = pending_start + i as u64;
388                if seq < start_seq {
389                    continue;
390                }
391                if result.len() >= limit {
392                    break;
393                }
394                result.push(event.clone());
395            }
396        }
397
398        Ok(result)
399    }
400
401    /// Delete all on-disk segment files whose items are fully covered by
402    /// `acked_seq`.
403    ///
404    /// A segment is deleted when its `end_seq <= acked_seq`. Returns the number
405    /// of segment files removed.
406    ///
407    /// # Example
408    ///
409    /// ```
410    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
411    /// use tempfile::tempdir;
412    ///
413    /// let dir = tempdir()?;
414    /// let buf: SegmentBuffer<u64> =
415    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
416    /// for i in 0..5u64 {
417    ///     buf.append(i)?;
418    /// }
419    /// buf.flush()?;
420    ///
421    /// // Consumer has processed sequence 0..=4; acknowledge them:
422    /// let removed = buf.delete_acked(4)?;
423    /// assert_eq!(removed, 1); // one segment file deleted
424    /// assert_eq!(buf.pending_count(), 0);
425    /// # Ok::<(), Box<dyn std::error::Error>>(())
426    /// ```
427    ///
428    /// # Limitation
429    ///
430    /// Acknowledgement only removes **flushed** segment files. Items still held
431    /// in the in-memory pending batch have no segment file to delete, so they
432    /// remain readable (and counted by [`SegmentBuffer::pending_count`]) until
433    /// they are flushed and acknowledged in a later call. `head_seq` is clamped
434    /// so it never advances past the pending window, keeping the backlog count
435    /// honest.
436    pub fn delete_acked(&self, acked_seq: u64) -> Result<usize> {
437        let segments = self.scan_segments()?;
438        let mut deleted = 0;
439        let mut freed_bytes: u64 = 0;
440        let mut new_head = None;
441
442        for seg in &segments {
443            if seg.end <= acked_seq {
444                let path = self.segment_path(seg.start, seg.end);
445                if let Ok(meta) = fs::metadata(&path) {
446                    freed_bytes += meta.len();
447                }
448                match fs::remove_file(&path) {
449                    Ok(()) => {
450                        deleted += 1;
451                        debug!(start = seg.start, end = seg.end, "Deleted acked segment");
452                    }
453                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
454                    Err(e) => return Err(e.into()),
455                }
456            } else if new_head.is_none() {
457                new_head = Some(seg.start);
458            }
459        }
460
461        {
462            let mut inner = self.inner.lock();
463            inner.approx_disk_bytes = inner.approx_disk_bytes.saturating_sub(freed_bytes);
464            // `head_seq` tracks the oldest unacked sequence. Clamp it to the
465            // start of the in-memory pending window: items still waiting to be
466            // flushed cannot be acknowledged (there is no segment file to
467            // delete), so head_seq must not advance past them. Without this
468            // clamp, acknowledging past a buffer that still holds unflushed
469            // items would make `pending_count` under-report the real backlog.
470            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
471            inner.head_seq = new_head.unwrap_or(inner.next_seq).min(pending_start);
472        }
473
474        if deleted > 0 {
475            info!(deleted, freed_bytes, acked_seq, "Deleted acked segments");
476        }
477
478        Ok(deleted)
479    }
480
481    /// The highest sequence number assigned (or 0 if buffer is empty).
482    ///
483    /// # Example
484    ///
485    /// ```
486    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
487    /// use tempfile::tempdir;
488    ///
489    /// let dir = tempdir()?;
490    /// let buf: SegmentBuffer<u64> =
491    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
492    ///
493    /// assert_eq!(buf.latest_sequence(), 0);
494    /// buf.append(7)?;
495    /// assert_eq!(buf.latest_sequence(), 0);
496    /// buf.append(8)?;
497    /// assert_eq!(buf.latest_sequence(), 1);
498    /// # Ok::<(), Box<dyn std::error::Error>>(())
499    /// ```
500    #[must_use = "the sequence number is meaningless if discarded"]
501    pub fn latest_sequence(&self) -> u64 {
502        let inner = self.inner.lock();
503        if inner.next_seq == 0 {
504            0
505        } else {
506            inner.next_seq - 1
507        }
508    }
509
510    /// Total items waiting in the buffer (on-disk + in-memory pending).
511    ///
512    /// Equivalent to `latest_sequence() - head_seq + 1` when non-empty, 0 when
513    /// empty. Decreases as [`delete_acked`](Self::delete_acked) removes files.
514    ///
515    /// # Example
516    ///
517    /// ```
518    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
519    /// use tempfile::tempdir;
520    ///
521    /// let dir = tempdir()?;
522    /// let buf: SegmentBuffer<u64> =
523    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
524    ///
525    /// assert_eq!(buf.pending_count(), 0);
526    /// buf.append(1)?;
527    /// buf.append(2)?;
528    /// assert_eq!(buf.pending_count(), 2);
529    /// buf.flush()?;
530    /// assert_eq!(buf.pending_count(), 2); // still pending until acked
531    /// buf.delete_acked(1)?;
532    /// assert_eq!(buf.pending_count(), 0);
533    /// # Ok::<(), Box<dyn std::error::Error>>(())
534    /// ```
535    #[must_use = "the backlog size is meaningless if discarded"]
536    pub fn pending_count(&self) -> u64 {
537        let inner = self.inner.lock();
538        inner.next_seq.saturating_sub(inner.head_seq)
539    }
540
541    /// Standard [`len`](#method.len) alias for [`pending_count`](Self::pending_count).
542    ///
543    /// Provided so `SegmentBuffer` reads like a normal collection at the call
544    /// site (`buf.len()`, `buf.is_empty()`). Same value as `pending_count()`,
545    /// kept as `u64` because the buffer is proven beyond `usize::MAX` on
546    /// 32-bit targets (597M+ events in monitor365).
547    ///
548    /// # Example
549    ///
550    /// ```
551    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
552    /// use tempfile::tempdir;
553    ///
554    /// let dir = tempdir()?;
555    /// let buf: SegmentBuffer<u64> =
556    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
557    /// assert!(buf.is_empty());
558    /// buf.append(7)?;
559    /// assert_eq!(buf.len(), 1);
560    /// assert!(!buf.is_empty());
561    /// # Ok::<(), Box<dyn std::error::Error>>(())
562    /// ```
563    #[must_use = "the backlog size is meaningless if discarded"]
564    pub fn len(&self) -> u64 {
565        self.pending_count()
566    }
567
568    /// `true` when there are no items waiting in the buffer (on-disk or
569    /// in-memory). Equivalent to `pending_count() == 0`.
570    ///
571    /// # Example
572    ///
573    /// ```
574    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
575    /// use tempfile::tempdir;
576    ///
577    /// let dir = tempdir()?;
578    /// let buf: SegmentBuffer<u64> =
579    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
580    /// assert!(buf.is_empty());
581    /// # Ok::<(), Box<dyn std::error::Error>>(())
582    /// ```
583    #[must_use = "the emptiness flag is meaningless if discarded"]
584    pub fn is_empty(&self) -> bool {
585        self.pending_count() == 0
586    }
587
588    /// Disk usage pressure as a value between 0.0 and 1.0.
589    ///
590    /// Use this to implement your own admission/backpressure policy (e.g.
591    /// reject low-priority items above 0.90, reject standard items above 0.95).
592    /// Returns 0.0 when `max_size_bytes == 0` (limit disabled).
593    ///
594    /// # Example
595    ///
596    /// ```
597    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
598    /// use tempfile::tempdir;
599    ///
600    /// let dir = tempdir()?;
601    /// let mut cfg = SegmentConfig::default();
602    /// cfg.max_size_bytes = 1000; // tiny limit so pressure is observable
603    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), cfg)?;
604    ///
605    /// assert!(buf.store_pressure() < 0.1);
606    /// # Ok::<(), Box<dyn std::error::Error>>(())
607    /// ```
608    #[must_use = "the pressure value is meaningless if discarded"]
609    pub fn store_pressure(&self) -> f32 {
610        let inner = self.inner.lock();
611        if self.config.max_size_bytes == 0 {
612            return 0.0;
613        }
614        (inner.approx_disk_bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
615    }
616
617    /// True when disk usage exceeds 90% of the configured limit.
618    ///
619    /// Convenience wrapper around `store_pressure() > 0.9`.
620    ///
621    /// # Example
622    ///
623    /// ```
624    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
625    /// use tempfile::tempdir;
626    ///
627    /// let dir = tempdir()?;
628    /// let buf: SegmentBuffer<u64> =
629    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
630    ///
631    /// assert!(!buf.is_overloaded());
632    /// # Ok::<(), Box<dyn std::error::Error>>(())
633    /// ```
634    #[must_use = "the overload flag is meaningless if discarded"]
635    pub fn is_overloaded(&self) -> bool {
636        self.store_pressure() > 0.9
637    }
638
639    /// Capture a consistent snapshot of buffer state under a single lock.
640    ///
641    /// Cheaper and more consistent than calling
642    /// [`pending_count`](Self::pending_count),
643    /// [`latest_sequence`](Self::latest_sequence),
644    /// [`store_pressure`](Self::store_pressure) etc. individually (which each
645    /// take the mutex and could observe a flush/delete between calls).
646    ///
647    /// # Performance
648    ///
649    /// Micro-benchmarked in `benches/bench_stats.rs` (run with
650    /// `cargo bench --bench bench_stats --features encryption`):
651    ///
652    /// | Operation                                  | Measured time (median, typical run) |
653    /// |--------------------------------------------|--------------------------------------|
654    /// | `stats()` (single lock, 7-field snapshot)  | ~12 ns                               |
655    /// | 3 individual accessors (`pending_count` + `latest_sequence` + `store_pressure`) | ~31 ns |
656    ///
657    /// So `stats()` is roughly **2.5× cheaper than 3 individual accessors**
658    /// while also being atomic — torn reads between calls are impossible.
659    /// Numbers are from the benchmark machine and fluctuate with hardware;
660    /// the relative ratio is the durable claim.
661    ///
662    /// # Example
663    ///
664    /// ```
665    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
666    /// use tempfile::tempdir;
667    ///
668    /// let dir = tempdir()?;
669    /// let buf: SegmentBuffer<u64> =
670    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
671    /// buf.append(1)?;
672    /// buf.append(2)?;
673    ///
674    /// let snapshot = buf.stats();
675    /// assert_eq!(snapshot.pending_count, 2);
676    /// assert_eq!(snapshot.next_sequence, 2);
677    /// assert!(snapshot.store_pressure < 0.01);
678    /// # Ok::<(), Box<dyn std::error::Error>>(())
679    /// ```
680    #[must_use = "the snapshot is meaningless if discarded"]
681    pub fn stats(&self) -> BufferStats {
682        let inner = self.inner.lock();
683        let pending_count = inner.next_seq.saturating_sub(inner.head_seq);
684        let latest_sequence = if inner.next_seq == 0 {
685            0
686        } else {
687            inner.next_seq - 1
688        };
689        let store_pressure = if self.config.max_size_bytes == 0 {
690            0.0
691        } else {
692            (inner.approx_disk_bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
693        };
694        BufferStats {
695            pending_count,
696            latest_sequence,
697            head_sequence: inner.head_seq,
698            next_sequence: inner.next_seq,
699            approx_disk_bytes: inner.approx_disk_bytes,
700            max_size_bytes: self.config.max_size_bytes,
701            store_pressure,
702        }
703    }
704
705    // -----------------------------------------------------------------------
706    // Internal helpers
707    // -----------------------------------------------------------------------
708
709    fn recover(&self) -> Result<()> {
710        segment::clean_tmp(&self.dir)?;
711
712        let segments = self.scan_segments()?;
713
714        // All filesystem access (stat'ing each segment for its size) happens
715        // BEFORE the mutex is taken. The lock is held only long enough to
716        // publish the rebuilt in-memory state, honouring the invariant that
717        // the mutex is never held across file I/O.
718        let total_bytes: u64 = segments
719            .iter()
720            .filter_map(|s| fs::metadata(self.segment_path(s.start, s.end)).ok())
721            .map(|m| m.len())
722            .sum();
723
724        let (head_seq, next_seq) = match (segments.first(), segments.last()) {
725            (Some(first), Some(last)) => (first.start, last.end + 1),
726            _ => (0, 0),
727        };
728
729        let segment_count = segments.len();
730        {
731            let mut inner = self.inner.lock();
732            inner.head_seq = head_seq;
733            inner.next_seq = next_seq;
734            inner.approx_disk_bytes = total_bytes;
735        }
736
737        info!(
738            segments = segment_count,
739            head_seq,
740            next_seq,
741            disk_bytes = total_bytes,
742            "Segment buffer recovered"
743        );
744
745        Ok(())
746    }
747
748    fn write_segment(&self, start: u64, end: u64, events: &[T]) -> Result<u64> {
749        segment::write(
750            &self.dir,
751            self.config.cipher.as_deref(),
752            self.config.compression_level,
753            SegmentRange::new(start, end),
754            events,
755        )
756    }
757
758    fn read_segment(&self, seg: SegmentRange) -> Result<Vec<T>> {
759        segment::read(&self.dir, self.config.cipher.as_deref(), seg)
760    }
761
762    fn scan_segments(&self) -> Result<Vec<SegmentRange>> {
763        segment::scan(&self.dir)
764    }
765
766    fn segment_path(&self, start: u64, end: u64) -> PathBuf {
767        self.dir.join(segment::filename(start, end))
768    }
769}
770
771// ---------------------------------------------------------------------------
772// Static thread-safety assertion
773// ---------------------------------------------------------------------------
774
775// `SegmentBuffer<T>` is documented as MPMC-safe via `parking_lot::Mutex`. This
776// fails to compile if anyone ever introduces a non-`Send`/`Sync` field on
777// `SegmentBuffer` or `BufferInner` (e.g. an `Rc`), turning the documented
778// thread-safety guarantee into a compile-time contract instead of a comment.
779const _: () = {
780    const fn assert_send_sync<T: Send + Sync>() {}
781    assert_send_sync::<SegmentBuffer<()>>();
782};
783
784#[cfg(test)]
785mod tests;
786
787#[cfg(test)]
788mod property_tests;