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