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//! For the full README — install, quickstart, encryption, backpressure,
27//! comparison table, and performance notes — see the
28//! [project README on GitHub](https://github.com/LarsArtmann/segment-buffer#segment-buffer)
29//! or [docs.rs](https://docs.rs/segment-buffer).
30
31#![warn(missing_docs)]
32#![doc = include_str!("../README.md")]
33
34mod cipher;
35mod error;
36mod segment;
37
38#[cfg(feature = "encryption")]
39pub use cipher::AesGcmCipher;
40pub use cipher::{CipherError, SegmentCipher};
41pub use error::{Result, SegmentError};
42
43use std::fs;
44use std::path::PathBuf;
45use std::time::Instant;
46
47use parking_lot::Mutex;
48use serde::de::DeserializeOwned;
49use serde::Serialize;
50use tracing::{debug, info};
51
52use segment::SegmentRange;
53
54/// When to auto-flush pending items from memory to a segment file.
55///
56/// Passed to [`SegmentConfig`] via its `flush_policy` field. Replaces the
57/// pre-v0.4.0 silent combination of two separate fields (`max_batch_events`
58/// and `flush_interval_secs`) that OR'd together without telling the caller
59/// which trigger fired.
60#[derive(Debug, Clone, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum FlushPolicy {
63    /// Flush as soon as `batch_size` items are buffered. No interval trigger.
64    Batch(usize),
65    /// Flush as soon as `interval` has elapsed since the last flush. No batch
66    /// trigger.
67    Interval(std::time::Duration),
68    /// Flush when EITHER `batch_size` items are buffered OR `interval` has
69    /// elapsed since the last flush — whichever fires first. This is the
70    /// pre-v0.4.0 default behavior.
71    BatchOrInterval {
72        /// In-memory item count threshold.
73        batch_size: usize,
74        /// Max time between flushes.
75        interval: std::time::Duration,
76    },
77    /// Never auto-flush. The caller must call [`SegmentBuffer::flush`]
78    /// explicitly to make appends durable. Useful for tests and for callers
79    /// that want absolute control over write amplification.
80    Manual,
81}
82
83impl Default for FlushPolicy {
84    fn default() -> Self {
85        // Matches the pre-v0.4.0 SegmentConfig::default: 256 events or 5s.
86        FlushPolicy::BatchOrInterval {
87            batch_size: 256,
88            interval: std::time::Duration::from_secs(5),
89        }
90    }
91}
92
93impl FlushPolicy {
94    /// Returns `true` when the policy says the buffer should flush now.
95    ///
96    /// `pending_len` is the current length of the in-memory `unflushed` Vec;
97    /// `time_since_last_flush` is `last_flush.elapsed()`.
98    fn should_flush(&self, pending_len: usize, time_since_last_flush: std::time::Duration) -> bool {
99        match self {
100            FlushPolicy::Batch(n) => pending_len >= *n,
101            FlushPolicy::Interval(d) => time_since_last_flush >= *d,
102            FlushPolicy::BatchOrInterval {
103                batch_size,
104                interval,
105            } => pending_len >= *batch_size || time_since_last_flush >= *interval,
106            FlushPolicy::Manual => false,
107        }
108    }
109}
110
111/// Configuration knobs for [`SegmentBuffer`].
112///
113/// This struct is `#[non_exhaustive]`: new fields may be added in any release
114/// without breaking semver. Construct via [`SegmentConfig::builder()`] and then
115/// mutate the public fields you care about, or use [`SegmentConfig::default()`]
116/// directly:
117///
118/// ```
119/// use segment_buffer::SegmentConfig;
120///
121/// let mut config = SegmentConfig::default();
122/// config.max_size_bytes = 1024 * 1024;
123/// ```
124#[non_exhaustive]
125pub struct SegmentConfig {
126    /// When to auto-flush pending items. See [`FlushPolicy`] for the options.
127    pub flush_policy: FlushPolicy,
128    /// Max total disk usage before the buffer reports overload pressure (default: 10 GB).
129    pub max_size_bytes: u64,
130    /// zstd compression level (1-22; 3 is fast with a good ratio).
131    pub compression_level: i32,
132    /// Optional cipher for encrypting segment files at rest. When `None`,
133    /// segments are written as plaintext zstd+CBOR.
134    pub cipher: Option<Box<dyn SegmentCipher>>,
135}
136
137impl std::fmt::Debug for SegmentConfig {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("SegmentConfig")
140            .field("flush_policy", &self.flush_policy)
141            .field("max_size_bytes", &self.max_size_bytes)
142            .field("compression_level", &self.compression_level)
143            .field("cipher", &self.cipher.as_ref().map(|_| "[set]"))
144            .finish()
145    }
146}
147
148impl Default for SegmentConfig {
149    fn default() -> Self {
150        Self {
151            flush_policy: FlushPolicy::default(),
152            max_size_bytes: 10 * 1024 * 1024 * 1024,
153            compression_level: 3,
154            cipher: None,
155        }
156    }
157}
158
159/// Ergonomic builder for [`SegmentConfig`].
160///
161/// `SegmentConfig` is `#[non_exhaustive]`, so direct struct-literal
162/// construction is forbidden outside the crate. The builder is the
163/// recommended way for callers to override one or two fields without
164/// re-typing every default.
165///
166/// ```
167/// use segment_buffer::{FlushPolicy, SegmentConfig};
168/// use std::time::Duration;
169///
170/// let config = SegmentConfig::builder()
171///     .flush_policy(FlushPolicy::Batch(64))
172///     .compression_level(6)
173///     .build();
174/// assert_eq!(config.flush_policy, FlushPolicy::Batch(64));
175/// assert_eq!(config.compression_level, 6);
176/// // Untouched fields fall back to Default.
177/// assert_eq!(config.max_size_bytes, 10 * 1024 * 1024 * 1024);
178/// ```
179#[derive(Debug)]
180pub struct SegmentConfigBuilder {
181    inner: SegmentConfig,
182}
183
184impl SegmentConfigBuilder {
185    /// Override the auto-flush policy. See [`FlushPolicy`] for variants.
186    pub fn flush_policy(mut self, policy: FlushPolicy) -> Self {
187        self.inner.flush_policy = policy;
188        self
189    }
190
191    /// Convenience: install a `FlushPolicy::Batch(batch_size)`.
192    pub fn flush_at_batch_size(self, batch_size: usize) -> Self {
193        self.flush_policy(FlushPolicy::Batch(batch_size))
194    }
195
196    /// Convenience: install a `FlushPolicy::Interval(interval)`.
197    pub fn flush_at_interval(self, interval: std::time::Duration) -> Self {
198        self.flush_policy(FlushPolicy::Interval(interval))
199    }
200
201    /// Convenience: install a `FlushPolicy::BatchOrInterval { .. }` with both
202    /// triggers set.
203    pub fn flush_at_batch_or_interval(
204        self,
205        batch_size: usize,
206        interval: std::time::Duration,
207    ) -> Self {
208        self.flush_policy(FlushPolicy::BatchOrInterval {
209            batch_size,
210            interval,
211        })
212    }
213
214    /// Convenience: install a `FlushPolicy::Manual` (no auto-flush).
215    pub fn flush_manually(self) -> Self {
216        self.flush_policy(FlushPolicy::Manual)
217    }
218
219    /// Override the disk-usage ceiling that triggers `is_overloaded()`.
220    pub fn max_size_bytes(mut self, max_size_bytes: u64) -> Self {
221        self.inner.max_size_bytes = max_size_bytes;
222        self
223    }
224
225    /// Override the zstd compression level (1–22; 3 is fast with a good ratio).
226    pub fn compression_level(mut self, compression_level: i32) -> Self {
227        self.inner.compression_level = compression_level;
228        self
229    }
230
231    /// Install a [`SegmentCipher`] so segment payloads are encrypted at rest.
232    pub fn cipher(mut self, cipher: Box<dyn SegmentCipher>) -> Self {
233        self.inner.cipher = Some(cipher);
234        self
235    }
236
237    /// Materialise the configured [`SegmentConfig`].
238    pub fn build(self) -> SegmentConfig {
239        self.inner
240    }
241}
242
243impl SegmentConfig {
244    /// Begin a builder. Every field starts at [`SegmentConfig::default`];
245    /// chain setter calls to override the ones you care about.
246    #[must_use = "the builder is meaningless if discarded"]
247    pub fn builder() -> SegmentConfigBuilder {
248        SegmentConfigBuilder {
249            inner: SegmentConfig::default(),
250        }
251    }
252}
253
254/// Point-in-time snapshot of buffer state, captured atomically under a single
255/// lock acquisition so all fields are mutually consistent.
256///
257/// Returned by [`SegmentBuffer::stats`]. Useful for metrics endpoints or
258/// dashboards that need to observe multiple values without paying for several
259/// lock/unlock round-trips (and risking a torn read between calls).
260///
261/// This struct is `#[non_exhaustive]`: new fields may be added in any release
262/// without breaking semver. It is constructed internally by [`SegmentBuffer::stats`];
263/// callers read fields via dot-syntax or pattern-match with `..` only.
264#[derive(Debug, Clone)]
265#[non_exhaustive]
266pub struct BufferStats {
267    /// Items waiting in the buffer (on-disk + in-memory pending).
268    /// Same value as [`SegmentBuffer::pending_count`].
269    pub pending_count: u64,
270    /// Highest sequence number assigned (or `0` if the buffer is empty).
271    /// Same value as [`SegmentBuffer::latest_sequence`].
272    pub latest_sequence: u64,
273    /// Oldest unacknowledged sequence number (`head_seq`).
274    pub head_sequence: u64,
275    /// Next sequence number that will be assigned by the next successful
276    /// [`SegmentBuffer::append`] (`next_seq`).
277    pub next_sequence: u64,
278    /// Approximate total bytes used by segment files on disk. Decreases when
279    /// [`SegmentBuffer::delete_acked`] removes files.
280    pub approx_disk_bytes: u64,
281    /// Configured ceiling on disk usage (`max_size_bytes`). `0` disables the
282    /// limit; in that case [`store_pressure`](Self::store_pressure) is `0.0`.
283    pub max_size_bytes: u64,
284    /// `approx_disk_bytes / max_size_bytes`, clamped to `[0.0, 1.0]`.
285    /// `0.0` when no limit is configured.
286    pub store_pressure: f32,
287}
288
289/// Summary of the recovery scan performed by [`SegmentBuffer::open`].
290///
291/// Returned by [`SegmentBuffer::open_with_report`] for programmatic
292/// introspection. The same data is logged via `tracing` from
293/// [`SegmentBuffer::open`]; this struct is for callers that want to inspect
294/// it without parsing logs.
295///
296/// All fields are snapshots taken during recovery — they may be stale by the
297/// time the caller reads them, because other threads can append/flush/delete
298/// immediately after `open` returns. For a live view, use
299/// [`SegmentBuffer::stats`].
300#[derive(Debug, Clone, PartialEq, Eq)]
301#[non_exhaustive]
302pub struct RecoveryReport {
303    /// Number of valid segment files found on disk during recovery.
304    pub segment_count: usize,
305    /// Oldest sequence number recovered (the `start` of the first segment),
306    /// or `0` when the directory was empty.
307    pub head_seq: u64,
308    /// Next sequence number that will be assigned by the next
309    /// [`SegmentBuffer::append`] (the `end + 1` of the last segment), or `0`
310    /// when the directory was empty.
311    pub next_seq: u64,
312    /// Total bytes of all recovered segment files (sum of file sizes).
313    pub disk_bytes: u64,
314    /// Number of `.tmp` debris files removed by recovery's cleanup step.
315    pub removed_tmp_files: usize,
316}
317
318struct BufferInner<T> {
319    /// Items buffered in memory, not yet written to a segment file. Drained by
320    /// [`SegmentBuffer::flush`] and rebuilt empty on crash recovery (unflushed
321    /// items do not survive a crash by design).
322    unflushed: Vec<T>,
323    next_seq: u64,
324    head_seq: u64,
325    last_flush: Instant,
326}
327
328/// Durable bounded queue of `T` backed by compressed segment files.
329///
330/// Thread-safe via `parking_lot::Mutex`. All file I/O is synchronous. The mutex
331/// is never held across an async boundary because there are no await points.
332///
333/// Create with [`SegmentBuffer::open`], supplying the directory and config.
334pub struct SegmentBuffer<T> {
335    dir: PathBuf,
336    config: SegmentConfig,
337    inner: Mutex<BufferInner<T>>,
338    /// Total bytes used by segment files on disk. Updated atomically on
339    /// flush/delete/recover so `flush()` does not need to re-acquire the
340    /// mutex just to bump one u64. Read by `store_pressure` and `stats`.
341    /// Deliberately approximate: the real number can drift if files are
342    /// touched outside this crate, so it is suitable for backpressure
343    /// signalling and metrics, NOT for billing.
344    approx_disk_bytes: std::sync::atomic::AtomicU64,
345    /// Cache of `scan_segments()`. `None` means stale (must re-scan); `Some`
346    /// means a flush/`delete_acked` has not touched the directory since the
347    /// last scan. The cache is invalidated by every on-disk mutation
348    /// (`flush`, `delete_acked`, `recover`) and never goes stale any other
349    /// way — operators who manipulate the directory behind the buffer's back
350    /// get the directory scan cost back.
351    scan_cache: Mutex<Option<Vec<segment::SegmentRange>>>,
352}
353
354/// `Debug` mirrors the field set of [`BufferStats`] plus the directory path.
355/// It does NOT print the in-memory `unflushed` items (which could be large or
356/// sensitive), so `T` itself is not required to be `Debug`.
357impl<T> std::fmt::Debug for SegmentBuffer<T>
358where
359    T: Serialize + DeserializeOwned + Clone + Send + 'static,
360{
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        let stats = self.stats();
363        f.debug_struct("SegmentBuffer")
364            .field("dir", &self.dir)
365            .field("pending_count", &stats.pending_count)
366            .field("latest_sequence", &stats.latest_sequence)
367            .field("head_sequence", &stats.head_sequence)
368            .field("next_sequence", &stats.next_sequence)
369            .field("approx_disk_bytes", &stats.approx_disk_bytes)
370            .field("max_size_bytes", &stats.max_size_bytes)
371            .field("store_pressure", &stats.store_pressure)
372            .finish()
373    }
374}
375
376impl<T> SegmentBuffer<T>
377where
378    T: Serialize + DeserializeOwned + Clone + Send + 'static,
379{
380    /// Open (or create) a buffer at `dir`, recovering from any existing
381    /// segment files.
382    ///
383    /// Recovery is **filename-based**: it scans the directory to rebuild
384    /// `head_seq` / `next_seq` and deletes leftover `.tmp` debris. Segment
385    /// *contents* are not read until [`read_from`](Self::read_from), so a
386    /// corrupted segment does not fail here — it fails when read.
387    ///
388    /// If you need the recovery summary (segments found, bytes, head/next seq)
389    /// programmatically, use [`SegmentBuffer::open_with_report`] instead. The
390    /// same data is logged via `tracing::info!` from this call.
391    ///
392    /// # Example
393    ///
394    /// ```
395    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
396    /// use tempfile::tempdir;
397    ///
398    /// let dir = tempdir()?;
399    /// let buf: SegmentBuffer<u64> =
400    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
401    /// # Ok::<(), Box<dyn std::error::Error>>(())
402    /// ```
403    ///
404    /// # Errors
405    ///
406    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
407    pub fn open(dir: impl Into<PathBuf>, config: SegmentConfig) -> Result<Self> {
408        let (buffer, _report) = Self::open_with_report(dir, config)?;
409        Ok(buffer)
410    }
411
412    /// Like [`SegmentBuffer::open`], but also returns a [`RecoveryReport`]
413    /// describing what the recovery scan found on disk.
414    ///
415    /// Useful for operational dashboards or migration tools that need to know
416    /// the on-disk state without re-scanning.
417    ///
418    /// # Example
419    ///
420    /// ```
421    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
422    /// use tempfile::tempdir;
423    ///
424    /// let dir = tempdir()?;
425    /// let (buf, report) =
426    ///     SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
427    /// assert_eq!(report.segment_count, 0); // fresh dir
428    /// assert_eq!(report.head_seq, 0);
429    /// assert_eq!(report.next_seq, 0);
430    /// # Ok::<(), Box<dyn std::error::Error>>(())
431    /// ```
432    ///
433    /// # Errors
434    ///
435    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
436    pub fn open_with_report(
437        dir: impl Into<PathBuf>,
438        config: SegmentConfig,
439    ) -> Result<(Self, RecoveryReport)> {
440        let dir = dir.into();
441        fs::create_dir_all(&dir)?;
442
443        let buffer = Self {
444            dir,
445            config,
446            inner: Mutex::new(BufferInner {
447                unflushed: Vec::new(),
448                next_seq: 0,
449                head_seq: 0,
450                last_flush: Instant::now(),
451            }),
452            approx_disk_bytes: std::sync::atomic::AtomicU64::new(0),
453            scan_cache: Mutex::new(None),
454        };
455
456        let report = buffer.recover()?;
457        Ok((buffer, report))
458    }
459
460    // -----------------------------------------------------------------------
461    // Public API
462    // -----------------------------------------------------------------------
463
464    /// Append an item to the buffer. Assigns the next sequence number and
465    /// auto-flushes if the batch threshold or interval is reached.
466    ///
467    /// Returns the assigned sequence number. The first append returns `0`,
468    /// and the number increments by 1 for each subsequent append.
469    ///
470    /// # Example
471    ///
472    /// ```
473    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
474    /// use tempfile::tempdir;
475    ///
476    /// let dir = tempdir()?;
477    /// let buf: SegmentBuffer<u64> =
478    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
479    ///
480    /// assert_eq!(buf.append(1)?, 0);
481    /// assert_eq!(buf.append(2)?, 1);
482    /// assert_eq!(buf.append(3)?, 2);
483    /// # Ok::<(), Box<dyn std::error::Error>>(())
484    /// ```
485    pub fn append(&self, event: T) -> Result<u64> {
486        let (should_flush, seq) = {
487            let mut inner = self.inner.lock();
488            inner.unflushed.push(event);
489            inner.next_seq += 1;
490            let seq = inner.next_seq - 1;
491
492            let should_flush = self
493                .config
494                .flush_policy
495                .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
496            (should_flush, seq)
497        };
498
499        if should_flush {
500            self.flush()?;
501        }
502
503        Ok(seq)
504    }
505
506    /// Flush buffered items to a segment file. No-op if nothing is buffered.
507    ///
508    /// Flushing is also triggered automatically by [`append`](Self::append)
509    /// according to the configured [`FlushPolicy`] (batch threshold, interval,
510    /// both, or manual). Call this explicitly when you need durability before
511    /// a known threshold, or when using [`FlushPolicy::Manual`].
512    ///
513    /// # Example
514    ///
515    /// ```
516    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
517    /// use tempfile::tempdir;
518    ///
519    /// let dir = tempdir()?;
520    /// let buf: SegmentBuffer<u64> =
521    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
522    /// buf.append(1)?;
523    /// buf.append(2)?;
524    ///
525    /// buf.flush()?; // items now durable on disk
526    /// assert_eq!(buf.pending_count(), 2);
527    /// # Ok::<(), Box<dyn std::error::Error>>(())
528    /// ```
529    pub fn flush(&self) -> Result<()> {
530        let (events, start_seq, end_seq) = {
531            let mut inner = self.inner.lock();
532            inner.last_flush = Instant::now();
533            if inner.unflushed.is_empty() {
534                return Ok(());
535            }
536            let events = std::mem::take(&mut inner.unflushed);
537            let count = events.len() as u64;
538            let end_seq = inner.next_seq - 1;
539            let start_seq = end_seq + 1 - count;
540            (events, start_seq, end_seq)
541        };
542
543        let compressed_len = self.write_segment(start_seq, end_seq, &events)?;
544
545        // approx_disk_bytes is now an AtomicU64, so flush() no longer needs
546        // to re-acquire the mutex just to bump one u64.
547        self.approx_disk_bytes
548            .fetch_add(compressed_len, std::sync::atomic::Ordering::Relaxed);
549        // A new segment file invalidates the directory-scan cache.
550        self.invalidate_scan_cache();
551
552        debug!(
553            path = self.segment_path(start_seq, end_seq).display().to_string(),
554            seq = start_seq,
555            end_seq,
556            count = events.len(),
557            bytes = compressed_len,
558            "Flushed segment"
559        );
560        Ok(())
561    }
562
563    /// Read up to `limit` items starting from `start_seq` (inclusive).
564    ///
565    /// Reads from both on-disk segment files and in-memory pending items.
566    /// Items are returned in ascending sequence order.
567    ///
568    /// Passing `limit = 0` returns an empty `Vec` without scanning.
569    ///
570    /// # Example
571    ///
572    /// ```
573    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
574    /// use tempfile::tempdir;
575    ///
576    /// let dir = tempdir()?;
577    /// let buf: SegmentBuffer<u64> =
578    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
579    /// buf.append(10)?;
580    /// buf.append(20)?;
581    /// buf.append(30)?;
582    /// buf.flush()?;
583    ///
584    /// let items = buf.read_from(0, 100)?;
585    /// assert_eq!(items, vec![10, 20, 30]);
586    ///
587    /// // start_seq skips already-read items:
588    /// let tail = buf.read_from(2, 100)?;
589    /// assert_eq!(tail, vec![30]);
590    /// # Ok::<(), Box<dyn std::error::Error>>(())
591    /// ```
592    pub fn read_from(&self, start_seq: u64, limit: usize) -> Result<Vec<T>> {
593        if limit == 0 {
594            return Ok(Vec::new());
595        }
596
597        let mut result: Vec<T> = Vec::with_capacity(limit.min(1024));
598
599        // Phase 1: read from on-disk segments.
600        let segments = self.scan_segments()?;
601        for seg in &segments {
602            if result.len() >= limit {
603                break;
604            }
605            if seg.end < start_seq {
606                continue;
607            }
608
609            let events = self.read_segment(*seg)?;
610            let skip = if seg.start < start_seq {
611                (start_seq - seg.start) as usize
612            } else {
613                0
614            };
615
616            for event in events.into_iter().skip(skip) {
617                if result.len() >= limit {
618                    break;
619                }
620                result.push(event);
621            }
622        }
623
624        // Phase 2: read from in-memory pending events.
625        if result.len() < limit {
626            let inner = self.inner.lock();
627            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
628            for (i, event) in inner.unflushed.iter().enumerate() {
629                let seq = pending_start + i as u64;
630                if seq < start_seq {
631                    continue;
632                }
633                if result.len() >= limit {
634                    break;
635                }
636                result.push(event.clone());
637            }
638        }
639
640        Ok(result)
641    }
642
643    /// Lending-iterator counterpart to [`read_from`](Self::read_from): invoke
644    /// `f(seq, item)` for up to `limit` items starting at `start_seq`, without
645    /// materialising them into a `Vec<T>`.
646    ///
647    /// This avoids the per-item `Clone` that [`read_from`](Self::read_from)
648    /// pays for in-memory pending items. On-disk segments still deserialize
649    /// into a temporary `Vec<T>` per segment (the on-disk format is bytes, not
650    /// `T`), but items are passed to `f` by reference rather than being
651    /// re-collected.
652    ///
653    /// Returns the number of items the callback was invoked for.
654    ///
655    /// # Performance
656    ///
657    /// Micro-benchmarked in `benches/bench_read_vs_for_each.rs` against
658    /// in-memory pending items (no segment files):
659    ///
660    /// | Items | `read_from` | `for_each_from` | Speedup |
661    /// |-------|-------------|-----------------|---------|
662    /// | 1,000 | ~26 µs      | ~1.2 µs         | ~21×    |
663    /// | 10,000| ~200 µs     | ~10 µs          | ~20×    |
664    ///
665    /// The speedup shrinks toward zero once on-disk segments dominate, because
666    /// both paths pay the same CBOR+zstd+cipher decode cost per segment — the
667    /// clone saving only applies to the in-memory tail.
668    ///
669    /// # Deadlock warning
670    ///
671    /// The mutex is held across `f` while iterating the in-memory pending
672    /// items. **Do NOT** call any other `&self` method on `SegmentBuffer`
673    /// from inside `f` — it will deadlock. (The callback receives only
674    /// `(seq, &T)`, which gives no way to reach the buffer, but a closure
675    /// that captures a clone of the `Arc<SegmentBuffer<T>>` can still
676    /// re-enter.)
677    ///
678    /// # Example
679    ///
680    /// ```
681    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
682    /// use tempfile::tempdir;
683    ///
684    /// let dir = tempdir()?;
685    /// let buf: SegmentBuffer<u64> =
686    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
687    /// for i in 0..5u64 {
688    ///     buf.append(i * 10)?;
689    /// }
690    /// buf.flush()?;
691    ///
692    /// let mut sum = 0u64;
693    /// let count = buf.for_each_from(0, 100, |_seq, item| { sum += *item; })?;
694    /// assert_eq!(count, 5);
695    /// assert_eq!(sum, 0 + 10 + 20 + 30 + 40);
696    /// # Ok::<(), Box<dyn std::error::Error>>(())
697    /// ```
698    pub fn for_each_from<F>(&self, start_seq: u64, limit: usize, mut f: F) -> Result<usize>
699    where
700        F: FnMut(u64, &T),
701    {
702        if limit == 0 {
703            return Ok(0);
704        }
705
706        let mut visited = 0usize;
707
708        // Phase 1: on-disk segments. Items are still deserialized into a per-
709        // segment Vec<T>, but each is handed to f by reference rather than
710        // being re-collected into the caller's Vec.
711        let segments = self.scan_segments()?;
712        for seg in &segments {
713            if visited >= limit {
714                break;
715            }
716            if seg.end < start_seq {
717                continue;
718            }
719
720            let events = self.read_segment(*seg)?;
721            let skip = if seg.start < start_seq {
722                (start_seq - seg.start) as usize
723            } else {
724                0
725            };
726
727            for (offset, event) in events.iter().enumerate().skip(skip) {
728                if visited >= limit {
729                    break;
730                }
731                let seq = seg.start + offset as u64;
732                f(seq, event);
733                visited += 1;
734            }
735        }
736
737        // Phase 2: in-memory pending items. Here the lending pattern wins:
738        // the items are borrowed in place under the lock, with zero clones.
739        if visited < limit {
740            let inner = self.inner.lock();
741            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
742            for (i, event) in inner.unflushed.iter().enumerate() {
743                if visited >= limit {
744                    break;
745                }
746                let seq = pending_start + i as u64;
747                if seq < start_seq {
748                    continue;
749                }
750                f(seq, event);
751                visited += 1;
752            }
753        }
754
755        Ok(visited)
756    }
757
758    /// Delete all on-disk segment files whose items are fully covered by
759    /// `acked_seq`.
760    ///
761    /// A segment is deleted when its `end_seq <= acked_seq`. Returns the number
762    /// of segment files removed.
763    ///
764    /// # Example
765    ///
766    /// ```
767    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
768    /// use tempfile::tempdir;
769    ///
770    /// let dir = tempdir()?;
771    /// let buf: SegmentBuffer<u64> =
772    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
773    /// for i in 0..5u64 {
774    ///     buf.append(i)?;
775    /// }
776    /// buf.flush()?;
777    ///
778    /// // Consumer has processed sequence 0..=4; acknowledge them:
779    /// let removed = buf.delete_acked(4)?;
780    /// assert_eq!(removed, 1); // one segment file deleted
781    /// assert_eq!(buf.pending_count(), 0);
782    /// # Ok::<(), Box<dyn std::error::Error>>(())
783    /// ```
784    ///
785    /// # Limitation
786    ///
787    /// Acknowledgement only removes **flushed** segment files. Items still held
788    /// in the in-memory pending batch have no segment file to delete, so they
789    /// remain readable (and counted by [`SegmentBuffer::pending_count`]) until
790    /// they are flushed and acknowledged in a later call. `head_seq` is clamped
791    /// so it never advances past the pending window, keeping the backlog count
792    /// honest.
793    pub fn delete_acked(&self, acked_seq: u64) -> Result<usize> {
794        let segments = self.scan_segments()?;
795        let mut deleted = 0;
796        let mut freed_bytes: u64 = 0;
797        let mut new_head = None;
798
799        for seg in &segments {
800            if seg.end <= acked_seq {
801                let path = self.segment_path(seg.start, seg.end);
802                let file_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
803                freed_bytes += file_bytes;
804                match fs::remove_file(&path) {
805                    Ok(()) => {
806                        deleted += 1;
807                        debug!(
808                            path = path.display().to_string(),
809                            seq = seg.start,
810                            end_seq = seg.end,
811                            bytes = file_bytes,
812                            "Deleted acked segment"
813                        );
814                    }
815                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
816                    Err(e) => return Err(e.into()),
817                }
818            } else if new_head.is_none() {
819                new_head = Some(seg.start);
820            }
821        }
822
823        // Subtract the freed bytes atomically; the lock is still needed for
824        // head_seq, but approx_disk_bytes can update independently.
825        self.approx_disk_bytes
826            .fetch_sub(freed_bytes, std::sync::atomic::Ordering::Relaxed);
827        // Deleted segment files invalidate the directory-scan cache.
828        self.invalidate_scan_cache();
829
830        {
831            let mut inner = self.inner.lock();
832            // `head_seq` tracks the oldest unacked sequence. Clamp it to the
833            // start of the in-memory pending window: items still waiting to be
834            // flushed cannot be acknowledged (there is no segment file to
835            // delete), so head_seq must not advance past them. Without this
836            // clamp, acknowledging past a buffer that still holds unflushed
837            // items would make `pending_count` under-report the real backlog.
838            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
839            inner.head_seq = new_head.unwrap_or(inner.next_seq).min(pending_start);
840        }
841
842        if deleted > 0 {
843            info!(
844                path = self.dir.display().to_string(),
845                deleted,
846                bytes = freed_bytes,
847                seq = acked_seq,
848                "Deleted acked segments"
849            );
850        }
851
852        Ok(deleted)
853    }
854
855    /// The highest sequence number assigned (or 0 if buffer is empty).
856    ///
857    /// # Example
858    ///
859    /// ```
860    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
861    /// use tempfile::tempdir;
862    ///
863    /// let dir = tempdir()?;
864    /// let buf: SegmentBuffer<u64> =
865    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
866    ///
867    /// assert_eq!(buf.latest_sequence(), 0);
868    /// buf.append(7)?;
869    /// assert_eq!(buf.latest_sequence(), 0);
870    /// buf.append(8)?;
871    /// assert_eq!(buf.latest_sequence(), 1);
872    /// # Ok::<(), Box<dyn std::error::Error>>(())
873    /// ```
874    #[must_use = "the sequence number is meaningless if discarded"]
875    pub fn latest_sequence(&self) -> u64 {
876        let inner = self.inner.lock();
877        if inner.next_seq == 0 {
878            0
879        } else {
880            inner.next_seq - 1
881        }
882    }
883
884    /// Total items waiting in the buffer (on-disk + in-memory pending).
885    ///
886    /// Equivalent to `latest_sequence() - head_seq + 1` when non-empty, 0 when
887    /// empty. Decreases as [`delete_acked`](Self::delete_acked) removes files.
888    ///
889    /// # Example
890    ///
891    /// ```
892    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
893    /// use tempfile::tempdir;
894    ///
895    /// let dir = tempdir()?;
896    /// let buf: SegmentBuffer<u64> =
897    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
898    ///
899    /// assert_eq!(buf.pending_count(), 0);
900    /// buf.append(1)?;
901    /// buf.append(2)?;
902    /// assert_eq!(buf.pending_count(), 2);
903    /// buf.flush()?;
904    /// assert_eq!(buf.pending_count(), 2); // still pending until acked
905    /// buf.delete_acked(1)?;
906    /// assert_eq!(buf.pending_count(), 0);
907    /// # Ok::<(), Box<dyn std::error::Error>>(())
908    /// ```
909    #[must_use = "the backlog size is meaningless if discarded"]
910    pub fn pending_count(&self) -> u64 {
911        let inner = self.inner.lock();
912        inner.next_seq.saturating_sub(inner.head_seq)
913    }
914
915    /// Standard [`len`](#method.len) alias for [`pending_count`](Self::pending_count).
916    ///
917    /// Provided so `SegmentBuffer` reads like a normal collection at the call
918    /// site (`buf.len()`, `buf.is_empty()`). Same value as `pending_count()`,
919    /// kept as `u64` because the buffer is proven beyond `usize::MAX` on
920    /// 32-bit targets (597M+ events in monitor365).
921    ///
922    /// # Example
923    ///
924    /// ```
925    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
926    /// use tempfile::tempdir;
927    ///
928    /// let dir = tempdir()?;
929    /// let buf: SegmentBuffer<u64> =
930    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
931    /// assert!(buf.is_empty());
932    /// buf.append(7)?;
933    /// assert_eq!(buf.len(), 1);
934    /// assert!(!buf.is_empty());
935    /// # Ok::<(), Box<dyn std::error::Error>>(())
936    /// ```
937    #[must_use = "the backlog size is meaningless if discarded"]
938    pub fn len(&self) -> u64 {
939        self.pending_count()
940    }
941
942    /// `true` when there are no items waiting in the buffer (on-disk or
943    /// in-memory). Equivalent to `pending_count() == 0`.
944    ///
945    /// # Example
946    ///
947    /// ```
948    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
949    /// use tempfile::tempdir;
950    ///
951    /// let dir = tempdir()?;
952    /// let buf: SegmentBuffer<u64> =
953    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
954    /// assert!(buf.is_empty());
955    /// # Ok::<(), Box<dyn std::error::Error>>(())
956    /// ```
957    #[must_use = "the emptiness flag is meaningless if discarded"]
958    pub fn is_empty(&self) -> bool {
959        self.pending_count() == 0
960    }
961
962    /// Disk usage pressure as a value between 0.0 and 1.0.
963    ///
964    /// Use this to implement your own admission/backpressure policy (e.g.
965    /// reject low-priority items above 0.90, reject standard items above 0.95).
966    /// Returns 0.0 when `max_size_bytes == 0` (limit disabled).
967    ///
968    /// # Example
969    ///
970    /// ```
971    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
972    /// use tempfile::tempdir;
973    ///
974    /// let dir = tempdir()?;
975    /// let mut cfg = SegmentConfig::default();
976    /// cfg.max_size_bytes = 1000; // tiny limit so pressure is observable
977    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), cfg)?;
978    ///
979    /// assert!(buf.store_pressure() < 0.1);
980    /// # Ok::<(), Box<dyn std::error::Error>>(())
981    /// ```
982    #[must_use = "the pressure value is meaningless if discarded"]
983    pub fn store_pressure(&self) -> f32 {
984        // store_pressure only needs approx_disk_bytes + max_size_bytes —
985        // neither requires the mutex. Read the atomic directly to avoid
986        // contending with append/flush.
987        if self.config.max_size_bytes == 0 {
988            return 0.0;
989        }
990        let bytes = self
991            .approx_disk_bytes
992            .load(std::sync::atomic::Ordering::Relaxed);
993        (bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
994    }
995
996    /// True when disk usage exceeds 90% of the configured limit.
997    ///
998    /// Convenience wrapper around `store_pressure() > 0.9`.
999    ///
1000    /// # Example
1001    ///
1002    /// ```
1003    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1004    /// use tempfile::tempdir;
1005    ///
1006    /// let dir = tempdir()?;
1007    /// let buf: SegmentBuffer<u64> =
1008    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1009    ///
1010    /// assert!(!buf.is_overloaded());
1011    /// # Ok::<(), Box<dyn std::error::Error>>(())
1012    /// ```
1013    #[must_use = "the overload flag is meaningless if discarded"]
1014    pub fn is_overloaded(&self) -> bool {
1015        self.store_pressure() > 0.9
1016    }
1017
1018    /// Capture a consistent snapshot of buffer state under a single lock.
1019    ///
1020    /// Cheaper and more consistent than calling
1021    /// [`pending_count`](Self::pending_count),
1022    /// [`latest_sequence`](Self::latest_sequence),
1023    /// [`store_pressure`](Self::store_pressure) etc. individually (which each
1024    /// take the mutex and could observe a flush/delete between calls).
1025    ///
1026    /// # Performance
1027    ///
1028    /// Micro-benchmarked in `benches/bench_stats.rs` (run with
1029    /// `cargo bench --bench bench_stats --features encryption`):
1030    ///
1031    /// | Operation                                  | Measured time (median, typical run) |
1032    /// |--------------------------------------------|--------------------------------------|
1033    /// | `stats()` (single lock, 7-field snapshot)  | ~12 ns                               |
1034    /// | 3 individual accessors (`pending_count` + `latest_sequence` + `store_pressure`) | ~31 ns |
1035    ///
1036    /// So `stats()` is roughly **2.5× cheaper than 3 individual accessors**
1037    /// while also being atomic — torn reads between calls are impossible.
1038    /// Numbers are from the benchmark machine and fluctuate with hardware;
1039    /// the relative ratio is the durable claim.
1040    ///
1041    /// # Example
1042    ///
1043    /// ```
1044    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1045    /// use tempfile::tempdir;
1046    ///
1047    /// let dir = tempdir()?;
1048    /// let buf: SegmentBuffer<u64> =
1049    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1050    /// buf.append(1)?;
1051    /// buf.append(2)?;
1052    ///
1053    /// let snapshot = buf.stats();
1054    /// assert_eq!(snapshot.pending_count, 2);
1055    /// assert_eq!(snapshot.next_sequence, 2);
1056    /// assert!(snapshot.store_pressure < 0.01);
1057    /// # Ok::<(), Box<dyn std::error::Error>>(())
1058    /// ```
1059    #[must_use = "the snapshot is meaningless if discarded"]
1060    pub fn stats(&self) -> BufferStats {
1061        let inner = self.inner.lock();
1062        let pending_count = inner.next_seq.saturating_sub(inner.head_seq);
1063        let latest_sequence = if inner.next_seq == 0 {
1064            0
1065        } else {
1066            inner.next_seq - 1
1067        };
1068        // Load the atomic OUTSIDE the mutex's critical section logic — the
1069        // value is approximate by design, so a torn read between this load
1070        // and the inner.lock() is acceptable.
1071        let approx_disk_bytes = self
1072            .approx_disk_bytes
1073            .load(std::sync::atomic::Ordering::Relaxed);
1074        let store_pressure = if self.config.max_size_bytes == 0 {
1075            0.0
1076        } else {
1077            (approx_disk_bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1078        };
1079        BufferStats {
1080            pending_count,
1081            latest_sequence,
1082            head_sequence: inner.head_seq,
1083            next_sequence: inner.next_seq,
1084            approx_disk_bytes,
1085            max_size_bytes: self.config.max_size_bytes,
1086            store_pressure,
1087        }
1088    }
1089
1090    // -----------------------------------------------------------------------
1091    // Internal helpers
1092    // -----------------------------------------------------------------------
1093
1094    fn recover(&self) -> Result<RecoveryReport> {
1095        let removed_tmp_files = segment::clean_tmp(&self.dir)?;
1096
1097        let segments = self.scan_segments()?;
1098
1099        // All filesystem access (stat'ing each segment for its size) happens
1100        // BEFORE the mutex is taken. The lock is held only long enough to
1101        // publish the rebuilt in-memory state, honouring the invariant that
1102        // the mutex is never held across file I/O.
1103        let total_bytes: u64 = segments
1104            .iter()
1105            .filter_map(|s| fs::metadata(self.segment_path(s.start, s.end)).ok())
1106            .map(|m| m.len())
1107            .sum();
1108
1109        let (head_seq, next_seq) = match (segments.first(), segments.last()) {
1110            (Some(first), Some(last)) => (first.start, last.end + 1),
1111            _ => (0, 0),
1112        };
1113
1114        let segment_count = segments.len();
1115        {
1116            let mut inner = self.inner.lock();
1117            inner.head_seq = head_seq;
1118            inner.next_seq = next_seq;
1119        }
1120        // Store the recovered disk-bytes total into the atomic directly.
1121        self.approx_disk_bytes
1122            .store(total_bytes, std::sync::atomic::Ordering::Relaxed);
1123        // Recovery just scanned the directory; populate the cache so the
1124        // first read_from/delete_acked after open does not re-scan.
1125        *self.scan_cache.lock() = Some(segments.clone());
1126
1127        info!(
1128            path = self.dir.display().to_string(),
1129            segments = segment_count,
1130            seq = head_seq,
1131            end_seq = next_seq,
1132            bytes = total_bytes,
1133            removed_tmp = removed_tmp_files,
1134            "Segment buffer recovered"
1135        );
1136
1137        Ok(RecoveryReport {
1138            segment_count,
1139            head_seq,
1140            next_seq,
1141            disk_bytes: total_bytes,
1142            removed_tmp_files,
1143        })
1144    }
1145
1146    fn write_segment(&self, start: u64, end: u64, events: &[T]) -> Result<u64> {
1147        let path = self.segment_path(start, end);
1148        segment::write(
1149            &self.dir,
1150            self.config.cipher.as_deref(),
1151            self.config.compression_level,
1152            SegmentRange::new(start, end),
1153            events,
1154        )
1155        .map_err(|e| e.with_path(&path))
1156    }
1157
1158    fn read_segment(&self, seg: SegmentRange) -> Result<Vec<T>> {
1159        let path = self.segment_path(seg.start, seg.end);
1160        segment::read(&self.dir, self.config.cipher.as_deref(), seg).map_err(|e| e.with_path(&path))
1161    }
1162
1163    fn scan_segments(&self) -> Result<Vec<SegmentRange>> {
1164        // Cache hit: clone under the cache lock and return.
1165        {
1166            let cache = self.scan_cache.lock();
1167            if let Some(ref segments) = *cache {
1168                return Ok(segments.clone());
1169            }
1170        }
1171        // Cache miss: scan the directory, store, return.
1172        let segments = segment::scan(&self.dir).map_err(|e| e.with_path(&self.dir))?;
1173        let mut cache = self.scan_cache.lock();
1174        *cache = Some(segments.clone());
1175        Ok(segments)
1176    }
1177
1178    /// Invalidate the scan cache. Called by every on-disk mutation
1179    /// (`flush`, `delete_acked`, `recover`).
1180    fn invalidate_scan_cache(&self) {
1181        let mut cache = self.scan_cache.lock();
1182        *cache = None;
1183    }
1184
1185    fn segment_path(&self, start: u64, end: u64) -> PathBuf {
1186        self.dir.join(segment::filename(start, end))
1187    }
1188}
1189
1190// ---------------------------------------------------------------------------
1191// Static thread-safety assertion
1192// ---------------------------------------------------------------------------
1193
1194// `SegmentBuffer<T>` is documented as MPMC-safe via `parking_lot::Mutex`. This
1195// fails to compile if anyone ever introduces a non-`Send`/`Sync` field on
1196// `SegmentBuffer` or `BufferInner` (e.g. an `Rc`), turning the documented
1197// thread-safety guarantee into a compile-time contract instead of a comment.
1198const _: () = {
1199    const fn assert_send_sync<T: Send + Sync>() {}
1200    assert_send_sync::<SegmentBuffer<()>>();
1201};
1202
1203#[cfg(test)]
1204mod tests;
1205
1206#[cfg(test)]
1207mod property_tests;