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