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`.
8//! (No explicit `'static` bound is required: `DeserializeOwned` already implies
9//! it, since a borrowed type cannot satisfy `for<'de> Deserialize<'de>`.)
10//! Crash recovery is filename-based: scanning the directory rebuilds `head_seq`
11//! and `next_seq` without any WAL or metadata database.
12//!
13//! # Example
14//!
15//! ```no_run
16//! use segment_buffer::{SegmentBuffer, SegmentConfig};
17//! use serde::{Serialize, Deserialize};
18//!
19//! #[derive(Serialize, Deserialize, Clone)]
20//! struct MyItem { id: u64 }
21//!
22//! let buffer = SegmentBuffer::<MyItem>::open("/tmp/my-queue", SegmentConfig::default())?;
23//! let seq = buffer.append(MyItem { id: 1 })?;
24//! let items = buffer.read_from(0, 100)?;
25//! # Ok::<(), Box<dyn std::error::Error>>(())
26//! ```
27//!
28//! For the full README — install, quickstart, encryption, backpressure,
29//! comparison table, and performance notes — see the
30//! [project README on GitHub](https://github.com/LarsArtmann/segment-buffer#segment-buffer)
31//! or [docs.rs](https://docs.rs/segment-buffer).
32
33#![warn(missing_docs)]
34// The crate-root rustdoc is the hand-written block above. The full README
35// (install, quickstart, encryption, comparison table, performance) is NOT
36// embedded here: it is rendered separately by docs.rs via the `readme` field
37// in Cargo.toml, and embedding it via `include_str!` caused two real problems
38// — (1) `craneLib.cleanCargoSource` strips README.md from the Nix sandbox,
39// needing a `postUnpack` band-aid, and (2) the README's cloud-sync doctest
40// referenced an undefined `cloud_upload` fn, turning `cargo test --doc` red.
41// Readers reach the README through the links above plus the docs.rs landing
42// page; the crate-root stays a concise, self-contained API orientation.
43
44mod cipher;
45mod error;
46mod segment;
47mod store;
48
49#[cfg(feature = "encryption")]
50pub use cipher::{AesGcmCipher, XChaCha20Poly1305Cipher};
51pub use cipher::{CipherError, SegmentCipher};
52pub use error::{IoSite, Result, SegmentError};
53
54/// Test/loom-only re-exports: the I/O trait, production impl, and the
55/// range type used in trait signatures.
56///
57/// Reachable only when the `loom` Cargo feature is enabled (used by the
58/// `tests/loom.rs` integration test to inject a mock store). Not part of
59/// the stable semver surface: items reachable through this re-export may
60/// change in any release without a major bump. Mirrors the gating strategy
61/// used by `fuzz_hooks`.
62#[cfg(feature = "loom")]
63pub use segment::SegmentRange;
64#[cfg(feature = "loom")]
65pub use store::{RealStore, SegmentStore};
66
67/// Internal helpers exposed for in-tree fuzz targets and deep integration tests.
68///
69/// **Not part of the public API.** Reachable only when the `fuzz` Cargo feature
70/// is enabled (or under `cfg(test)`). Stability is not guaranteed — these may
71/// change or disappear in any release without bumping the major version.
72///
73/// Rationale: `#[doc(hidden)]` hides items from rustdoc but does **not** remove
74/// them from the semver surface. A `#[cfg]`-gated module does both: it disappears
75/// from docs *and* from the compiled crate when the feature is off, so downstream
76/// users who never opted into `fuzz` cannot reach these items at all. See
77/// `CONTRIBUTING.md` → "Internal hooks: `#[cfg]` over `#[doc(hidden)]`".
78#[cfg(any(test, feature = "fuzz"))]
79pub mod fuzz_hooks {
80    pub use crate::segment::{
81        filename, parse_filename, unwrap_envelope, wrap_envelope, SegmentRange,
82    };
83}
84
85use std::path::PathBuf;
86use std::sync::Arc;
87use std::time::Instant;
88
89use parking_lot::Mutex;
90use serde::de::DeserializeOwned;
91use serde::Serialize;
92use tracing::{debug, info};
93
94/// Filename of the single-process lock sidecar held open by every production
95/// [`SegmentBuffer`]. Lives inside the segment directory and is acquired
96/// exclusively at [`SegmentBuffer::open`]; the kernel releases the lock when
97/// the buffer is dropped (closing the fd). Loom-test opens
98/// ([`SegmentBuffer::open_with_store`]) skip the lock — loom does not model
99/// the filesystem, and a real lock file inside `loom::model` would deadlock.
100const LOCK_FILE_NAME: &str = ".segment-buffer.lock";
101
102/// When to auto-flush pending items from memory to a segment file.
103///
104/// Passed to [`SegmentConfig`] via its `flush_policy` field. Replaces the
105/// pre-v0.4.0 silent combination of two separate fields (`max_batch_events`
106/// and `flush_interval_secs`) that OR'd together without telling the caller
107/// which trigger fired.
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[non_exhaustive]
110pub enum FlushPolicy {
111    /// Flush as soon as `batch_size` items are buffered. No interval trigger.
112    Batch(usize),
113    /// Flush as soon as `interval` has elapsed since the last flush. No batch
114    /// trigger.
115    Interval(std::time::Duration),
116    /// Flush when EITHER `batch_size` items are buffered OR `interval` has
117    /// elapsed since the last flush — whichever fires first. This is the
118    /// pre-v0.4.0 default behavior.
119    BatchOrInterval {
120        /// In-memory item count threshold.
121        batch_size: usize,
122        /// Max time between flushes.
123        interval: std::time::Duration,
124    },
125    /// Never auto-flush. The caller must call [`SegmentBuffer::flush`]
126    /// explicitly to make appends durable. Useful for tests and for callers
127    /// that want absolute control over write amplification.
128    Manual,
129}
130
131impl Default for FlushPolicy {
132    fn default() -> Self {
133        // Matches the pre-v0.4.0 SegmentConfig::default: 256 events or 5s.
134        FlushPolicy::BatchOrInterval {
135            batch_size: 256,
136            interval: std::time::Duration::from_secs(5),
137        }
138    }
139}
140
141impl FlushPolicy {
142    /// Returns `true` when the policy says the buffer should flush now.
143    ///
144    /// `pending_len` is the current length of the in-memory `unflushed` Vec;
145    /// `time_since_last_flush` is `last_flush.elapsed()`.
146    fn should_flush(&self, pending_len: usize, time_since_last_flush: std::time::Duration) -> bool {
147        match self {
148            FlushPolicy::Batch(n) => pending_len >= *n,
149            FlushPolicy::Interval(d) => time_since_last_flush >= *d,
150            FlushPolicy::BatchOrInterval {
151                batch_size,
152                interval,
153            } => pending_len >= *batch_size || time_since_last_flush >= *interval,
154            FlushPolicy::Manual => false,
155        }
156    }
157}
158
159/// Per-flush durability tradeoff between throughput and crash safety.
160///
161/// Selects how many `fsync`s the write path performs when [`flush`](SegmentBuffer::flush)
162/// spills a batch to disk. Higher durability costs throughput; lower
163/// durability relies on the cloud (or wherever the durable copy lives) to
164/// absorb crash loss. The cloud-sync vision for this crate makes
165/// [`Throughput`](Self::Throughput) the natural default once callers opt in,
166/// but [`Segment`](Self::Segment) remains the default for one release after
167/// the enum lands to avoid silently changing crash semantics for existing
168/// users.
169///
170/// # Crash-loss semantics
171///
172/// | Policy                 | Fsync file data | Fsync dir after rename | Worst-case crash loss                                |
173/// | ---------------------- | --------------- | --------------------- | ---------------------------------------------------- |
174/// | [`Maximal`](Self::Maximal)    | yes             | yes                   | last in-flight flush only                            |
175/// | [`Segment`](Self::Segment)    | yes             | no                    | rename window (~5–30s of flushes on ext4/xfs)        |
176/// | [`Throughput`](Self::Throughput) | no              | no                    | entire OS dirty window (~30s) — cloud is durable     |
177///
178/// `Maximal` is for standalone-queue deployments where this buffer is the
179/// last copy. `Throughput` is the correct choice for cloud-sync deployments
180/// where the cloud endpoint holds the durable copy and the local disk is a
181/// throughput buffer. `Segment` is the pre-v0.5.0 behavior, kept as the
182/// default for one release for backward compatibility.
183///
184/// # The rename-window gap (why `Segment` is not "fully durable")
185///
186/// `Segment` (today's default) calls `file.sync_all()` on the segment data
187/// before `fs::rename`, but it does **not** `dir.sync_all()` after the
188/// rename. On ext4/xfs defaults, a host crash within the kernel's dir-inode
189/// flush window (~5–30s) can leave the renamed file's data on disk but
190/// unreachable through the directory. SQLite went through this exact lesson.
191/// So `Segment` was already not fully durable; the enum just makes the
192/// tradeoff explicit. `Maximal` closes the rename-window gap.
193///
194/// # Implementation
195///
196/// The policy is branched on inside `SegmentStore::write_atomic`
197/// (not a callback): it is a `Copy` enum with no allocation, and the
198/// `Mutex<Compressor>` invariant ("never held across I/O") is preserved
199/// because the fsync happens after compression is done and the mutex is
200/// released.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202#[non_exhaustive]
203pub enum DurabilityPolicy {
204    /// Fsync the segment file's data **and** the parent directory inode
205    /// after rename. Closes the rename-window gap. Use when this buffer is
206    /// the last copy of the data (standalone-queue deployments).
207    Maximal,
208
209    /// Fsync the segment file's data, but not the directory inode after
210    /// rename. This is the pre-v0.5.0 behavior. Kept as the
211    /// [`Default`] for one release after the enum
212    /// lands, then flips to [`Throughput`](Self::Throughput) with a
213    /// deprecation note.
214    #[default]
215    Segment,
216
217    /// Skip fsync entirely. The kernel's dirty-page flusher handles when the
218    /// bytes reach disk (~30s on default Linux). The rename is still atomic,
219    /// so concurrent readers never see a partial write — only a host crash
220    /// within the dirty window can lose the segment. Use when the cloud is
221    /// the durable layer and this buffer is the throughput buffer in front
222    /// of it.
223    Throughput,
224}
225
226/// Configuration knobs for [`SegmentBuffer`].
227///
228/// This struct is `#[non_exhaustive]`: new fields may be added in any release
229/// without breaking semver. Construct via [`SegmentConfig::builder()`] and then
230/// mutate the public fields you care about, or use [`SegmentConfig::default()`]
231/// directly:
232///
233/// ```
234/// use segment_buffer::SegmentConfig;
235///
236/// let mut config = SegmentConfig::default();
237/// config.max_size_bytes = 1024 * 1024;
238/// ```
239#[non_exhaustive]
240#[derive(Clone)]
241pub struct SegmentConfig {
242    /// When to auto-flush pending items. See [`FlushPolicy`] for the options.
243    pub flush_policy: FlushPolicy,
244    /// Max total disk usage before the buffer reports overload pressure (default: 10 GB).
245    pub max_size_bytes: u64,
246    /// zstd compression level (1-22; 3 is fast with a good ratio).
247    pub compression_level: i32,
248    /// Per-flush fsync behavior. See [`DurabilityPolicy`] for the three
249    /// policies and their crash-loss tradeoffs. Default is
250    /// [`DurabilityPolicy::Segment`] (today's behavior) for backward
251    /// compatibility; cloud-sync deployments should switch to
252    /// [`DurabilityPolicy::Throughput`] once the cloud endpoint holds the
253    /// durable copy.
254    pub durability: DurabilityPolicy,
255    /// Optional cipher for encrypting segment files at rest. When `None`,
256    /// segments are written as plaintext zstd+CBOR. Held as an [`Arc`] so a
257    /// [`SegmentConfig`] is [`Clone`] and the same cipher can be shared
258    /// across multiple buffers or cloned into a `recommended_cipher()` helper.
259    pub cipher: Option<Arc<dyn SegmentCipher + Send + Sync>>,
260}
261
262impl std::fmt::Debug for SegmentConfig {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.debug_struct("SegmentConfig")
265            .field("flush_policy", &self.flush_policy)
266            .field("max_size_bytes", &self.max_size_bytes)
267            .field("compression_level", &self.compression_level)
268            .field("durability", &self.durability)
269            .field("cipher", &self.cipher.as_ref().map(|_| "[set]"))
270            .finish()
271    }
272}
273
274impl Default for SegmentConfig {
275    fn default() -> Self {
276        Self {
277            flush_policy: FlushPolicy::default(),
278            max_size_bytes: 10 * 1024 * 1024 * 1024,
279            compression_level: 3,
280            durability: DurabilityPolicy::default(),
281            cipher: None,
282        }
283    }
284}
285
286/// Ergonomic builder for [`SegmentConfig`].
287///
288/// `SegmentConfig` is `#[non_exhaustive]`, so direct struct-literal
289/// construction is forbidden outside the crate. The builder is the
290/// recommended way for callers to override one or two fields without
291/// re-typing every default.
292///
293/// ```
294/// use segment_buffer::{FlushPolicy, SegmentConfig};
295/// use std::time::Duration;
296///
297/// let config = SegmentConfig::builder()
298///     .flush_policy(FlushPolicy::Batch(64))
299///     .compression_level(6)
300///     .build();
301/// assert_eq!(config.flush_policy, FlushPolicy::Batch(64));
302/// assert_eq!(config.compression_level, 6);
303/// // Untouched fields fall back to Default.
304/// assert_eq!(config.max_size_bytes, 10 * 1024 * 1024 * 1024);
305/// ```
306#[derive(Debug, Clone)]
307pub struct SegmentConfigBuilder {
308    inner: SegmentConfig,
309}
310
311impl SegmentConfigBuilder {
312    /// Override the auto-flush policy. See [`FlushPolicy`] for variants.
313    pub fn flush_policy(mut self, policy: FlushPolicy) -> Self {
314        self.inner.flush_policy = policy;
315        self
316    }
317
318    /// Convenience: install a `FlushPolicy::Batch(batch_size)`.
319    pub fn flush_at_batch_size(self, batch_size: usize) -> Self {
320        self.flush_policy(FlushPolicy::Batch(batch_size))
321    }
322
323    /// Convenience: install a `FlushPolicy::Interval(interval)`.
324    pub fn flush_at_interval(self, interval: std::time::Duration) -> Self {
325        self.flush_policy(FlushPolicy::Interval(interval))
326    }
327
328    /// Convenience: install a `FlushPolicy::BatchOrInterval { .. }` with both
329    /// triggers set.
330    pub fn flush_at_batch_or_interval(
331        self,
332        batch_size: usize,
333        interval: std::time::Duration,
334    ) -> Self {
335        self.flush_policy(FlushPolicy::BatchOrInterval {
336            batch_size,
337            interval,
338        })
339    }
340
341    /// Convenience: install a `FlushPolicy::Manual` (no auto-flush).
342    pub fn flush_manually(self) -> Self {
343        self.flush_policy(FlushPolicy::Manual)
344    }
345
346    /// Override the disk-usage ceiling that triggers `is_overloaded()`.
347    pub fn max_size_bytes(mut self, max_size_bytes: u64) -> Self {
348        self.inner.max_size_bytes = max_size_bytes;
349        self
350    }
351
352    /// Override the zstd compression level (1–22; 3 is fast with a good ratio).
353    pub fn compression_level(mut self, compression_level: i32) -> Self {
354        self.inner.compression_level = compression_level;
355        self
356    }
357
358    /// Override the per-flush durability policy. See [`DurabilityPolicy`] for
359    /// the three policies and their crash-loss tradeoffs.
360    ///
361    /// The default is [`DurabilityPolicy::Segment`] for backward
362    /// compatibility. For cloud-sync deployments where the cloud endpoint
363    /// holds the durable copy, [`DurabilityPolicy::Throughput`] eliminates
364    /// the per-flush fsync from the hot path (typically a 5–10× win on fast
365    /// storage).
366    pub fn durability(mut self, policy: DurabilityPolicy) -> Self {
367        self.inner.durability = policy;
368        self
369    }
370
371    /// Install a [`SegmentCipher`] so segment payloads are encrypted at rest.
372    ///
373    /// Accepts an [`Arc`] so the same cipher can be shared across multiple
374    /// buffers or cloned into a `recommended_cipher()` helper. The canonical
375    /// construction pattern is:
376    ///
377    /// ```no_run
378    /// # #[cfg(feature = "encryption")] {
379    /// use segment_buffer::{AesGcmCipher, SegmentConfig};
380    /// use std::sync::Arc;
381    /// let cfg = SegmentConfig::builder()
382    ///     .cipher(Arc::new(AesGcmCipher::new(&[0u8; 32])))
383    ///     .build();
384    /// # }
385    /// ```
386    pub fn cipher(mut self, cipher: Arc<dyn SegmentCipher + Send + Sync>) -> Self {
387        self.inner.cipher = Some(cipher);
388        self
389    }
390
391    /// Install the cipher this crate recommends for **new buffers**.
392    ///
393    /// Available only under the `encryption` feature. Picks
394    /// [`XChaCha20Poly1305Cipher`] (24-byte extended nonce, no 2³²-message
395    /// limit per key, constant-time on hosts without AES-NI). Legacy
396    /// AES-GCM segments still decrypt through [`AesGcmCipher`]; the two
397    /// formats are byte-distinguishable only by which cipher the buffer
398    /// was opened with.
399    ///
400    /// # Example
401    ///
402    /// ```no_run
403    /// # #[cfg(feature = "encryption")] {
404    /// use segment_buffer::SegmentConfig;
405    /// let cfg = SegmentConfig::builder()
406    ///     .recommended_cipher([0u8; 32])
407    ///     .build();
408    /// # }
409    /// ```
410    #[cfg(feature = "encryption")]
411    pub fn recommended_cipher(self, key: [u8; 32]) -> Self {
412        self.cipher(Arc::new(XChaCha20Poly1305Cipher::new(&key)))
413    }
414
415    /// Materialise the configured [`SegmentConfig`].
416    pub fn build(self) -> SegmentConfig {
417        self.inner
418    }
419}
420
421impl SegmentConfig {
422    /// Begin a builder. Every field starts at [`SegmentConfig::default`];
423    /// chain setter calls to override the ones you care about.
424    #[must_use = "the builder is meaningless if discarded"]
425    pub fn builder() -> SegmentConfigBuilder {
426        SegmentConfigBuilder {
427            inner: SegmentConfig::default(),
428        }
429    }
430}
431
432/// Point-in-time snapshot of buffer state, captured atomically under a single
433/// lock acquisition so all fields are mutually consistent.
434///
435/// Returned by [`SegmentBuffer::stats`]. Useful for metrics endpoints or
436/// dashboards that need to observe multiple values without paying for several
437/// lock/unlock round-trips (and risking a torn read between calls).
438///
439/// This struct is `#[non_exhaustive]`: new fields may be added in any release
440/// without breaking semver. It is constructed internally by [`SegmentBuffer::stats`];
441/// callers read fields via dot-syntax or pattern-match with `..` only.
442#[derive(Debug, Clone)]
443#[non_exhaustive]
444pub struct BufferStats {
445    /// Items waiting in the buffer (on-disk + in-memory pending).
446    /// Same value as [`SegmentBuffer::pending_count`].
447    pub pending_count: u64,
448    /// Highest sequence number assigned (or `0` if the buffer is empty).
449    /// Same value as [`SegmentBuffer::latest_sequence`].
450    pub latest_sequence: u64,
451    /// Oldest unacknowledged sequence number (`head_seq`).
452    pub head_sequence: u64,
453    /// Next sequence number that will be assigned by the next successful
454    /// [`SegmentBuffer::append`] (`next_seq`).
455    pub next_sequence: u64,
456    /// Approximate total bytes used by segment files on disk. Decreases when
457    /// [`SegmentBuffer::delete_acked`] removes files.
458    pub approx_disk_bytes: u64,
459    /// Configured ceiling on disk usage (`max_size_bytes`). `0` disables the
460    /// limit; in that case [`store_pressure`](Self::store_pressure) is `0.0`.
461    pub max_size_bytes: u64,
462    /// `approx_disk_bytes / max_size_bytes`, clamped to `[0.0, 1.0]`.
463    /// `0.0` when no limit is configured.
464    pub store_pressure: f32,
465}
466
467/// Summary of the recovery scan performed by [`SegmentBuffer::open`].
468///
469/// Returned by [`SegmentBuffer::open_with_report`] for programmatic
470/// introspection. The same data is logged via `tracing` from
471/// [`SegmentBuffer::open`]; this struct is for callers that want to inspect
472/// it without parsing logs.
473///
474/// All fields are snapshots taken during recovery — they may be stale by the
475/// time the caller reads them, because other threads can append/flush/delete
476/// immediately after `open` returns. For a live view, use
477/// [`SegmentBuffer::stats`].
478///
479/// # Recovering over a populated directory
480///
481/// ```
482/// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
483/// use tempfile::tempdir;
484///
485/// let dir = tempdir()?;
486///
487/// // First instance: write three items, flush, drop.
488/// {
489///     let config = SegmentConfig::builder()
490///         .flush_policy(FlushPolicy::Manual)
491///         .build();
492///     let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
493///     for i in 0..3u64 { buf.append(i)?; }
494///     buf.flush()?;
495/// }
496///
497/// // Re-open: recovery must find one segment covering seqs 0..=2.
498/// let (buf, report) =
499///     SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
500/// assert_eq!(report.segment_count, 1);
501/// assert_eq!(report.head_seq, 0);
502/// assert_eq!(report.next_seq, 3);
503/// assert!(report.disk_bytes > 0, "flushed segment must have nonzero size");
504/// assert_eq!(report.removed_tmp_files, 0);
505/// # Ok::<(), Box<dyn std::error::Error>>(())
506/// ```
507#[derive(Debug, Clone, PartialEq, Eq)]
508#[non_exhaustive]
509pub struct RecoveryReport {
510    /// Number of valid segment files found on disk during recovery.
511    pub segment_count: usize,
512    /// Oldest sequence number recovered (the `start` of the first segment),
513    /// or `0` when the directory was empty.
514    pub head_seq: u64,
515    /// Next sequence number that will be assigned by the next
516    /// [`SegmentBuffer::append`] (the `end + 1` of the last segment), or `0`
517    /// when the directory was empty.
518    pub next_seq: u64,
519    /// Total bytes of all recovered segment files (sum of file sizes).
520    pub disk_bytes: u64,
521    /// Number of `.tmp` debris files removed by recovery's cleanup step.
522    pub removed_tmp_files: usize,
523}
524
525struct BufferInner<T> {
526    /// Items buffered in memory, not yet written to a segment file. Drained by
527    /// [`SegmentBuffer::flush`] and rebuilt empty on crash recovery (unflushed
528    /// items do not survive a crash by design).
529    unflushed: Vec<T>,
530    next_seq: u64,
531    head_seq: u64,
532    last_flush: Instant,
533}
534
535/// Durable bounded queue of `T` backed by compressed segment files.
536///
537/// Thread-safe via `parking_lot::Mutex`. All file I/O is synchronous. The mutex
538/// is never held across an async boundary because there are no await points.
539///
540/// Create with [`SegmentBuffer::open`], supplying the directory and config.
541pub struct SegmentBuffer<T> {
542    dir: PathBuf,
543    config: SegmentConfig,
544    inner: Mutex<BufferInner<T>>,
545    /// Total bytes used by segment files on disk. Updated atomically on
546    /// flush/delete/recover so `flush()` does not need to re-acquire the
547    /// mutex just to bump one u64. Read by `store_pressure` and `stats`.
548    /// Deliberately approximate: the real number can drift if files are
549    /// touched outside this crate, so it is suitable for backpressure
550    /// signalling and metrics, NOT for billing.
551    approx_disk_bytes: std::sync::atomic::AtomicU64,
552    /// Cache of `scan_segments()`. `None` means stale (must re-scan); `Some`
553    /// means a flush/`delete_acked` has not touched the directory since the
554    /// last scan. The cache is invalidated by every on-disk mutation
555    /// (`flush`, `delete_acked`, `recover`) and never goes stale any other
556    /// way — operators who manipulate the directory behind the buffer's back
557    /// get the directory scan cost back.
558    scan_cache: Mutex<Option<Vec<segment::SegmentRange>>>,
559    /// Re-entrancy guard for [`SegmentBuffer::for_each_from`]. Set to `true`
560    /// for the duration of a `for_each_from` call (including across the user
561    /// callback `F`); every other `&self` method that takes `inner.lock()`
562    /// asserts this is `false` and panics with a clear message otherwise.
563    ///
564    /// This converts the silent deadlock of re-entering the buffer from inside
565    /// a `for_each_from` callback into an immediate, diagnosable panic. The
566    /// atomic load costs ~1 ns per locking op — negligible next to the mutex.
567    iteration_in_progress: std::sync::atomic::AtomicBool,
568    /// Pooled zstd compression context, allocated once at [`SegmentBuffer::open`]
569    /// and reused for every subsequent [`SegmentBuffer::flush`]. The flamegraph
570    /// captured on 2026-07-20 (see `docs/perf/2026-07-20_hot-path-flamegraph.md`)
571    /// showed 66% of `flush` CPU time was inside the `__memset` that
572    /// `zstd::encode_all` triggers when it constructs a fresh ~200 KB `CCtx`
573    /// per call. Pooling the `CCtx` through `zstd::bulk::Compressor` reduces
574    /// that init cost to a one-time `open` expense; subsequent flushes reuse
575    /// the same internal tables and pay only the per-frame `SessionOnly` reset
576    /// (~0.2% of CPU in the same profile).
577    ///
578    /// Behind its own `Mutex` (rather than living inside `BufferInner`) so
579    /// that holding it during the compression step does not extend the
580    /// hot-path `inner` mutex hold time. The mutex is uncontended in
581    /// practice: `flush` already takes `inner.lock()` briefly to drain the
582    /// pending events, and the re-entrancy guard serialises concurrent
583    /// flushers against `for_each_from` anyway.
584    compressor: Mutex<zstd::bulk::Compressor<'static>>,
585    /// Pooled zstd decompression context — the read-side mirror of
586    /// [`compressor`](Self::compressor). Allocated once at
587    /// [`SegmentBuffer::open`] and reused for every subsequent
588    /// [`SegmentBuffer::read_from`] / [`SegmentBuffer::for_each_from`] call.
589    /// Cloud-sync drain loops are read-heavy (draining the buffer is the
590    /// primary workload), so the DCtx pooling matters symmetrically to the
591    /// CCtx pooling on the write side. Falls back to `zstd::decode_all`
592    /// (fresh DCtx per call) only when the frame header lacks a content
593    /// size — the bulk::Compressor write path always includes it, so the
594    /// fallback is rare in practice (legacy or externally-written files).
595    decompressor: Mutex<zstd::bulk::Decompressor<'static>>,
596    /// I/O backend. Production uses [`RealStore`] (real filesystem via
597    /// `std::fs`); loom concurrency tests inject a mock backed by
598    /// `loom::sync::Mutex<HashMap<..>>` so `delete_acked` + `append`
599    /// interleavings can be enumerated exhaustively without modelling the
600    /// kernel filesystem. The trait object costs ~5 ns per I/O call
601    /// (negligible next to zstd+CBOR+file I/O) and is constructed internally
602    /// by [`open`](Self::open), so callers never see it. The store is always
603    /// called OUTSIDE the `inner` mutex — see [`flush`](Self::flush) and
604    /// [`delete_acked`](Self::delete_acked) for the lock-release boundaries.
605    store: Arc<dyn store::SegmentStore + Send + Sync>,
606    /// File handle holding the exclusive single-process `flock` on
607    /// `<dir>/.segment-buffer.lock`. Acquired by `open_internal` BEFORE any
608    /// recovery scans or state publication; released by `Drop` (closing the
609    /// fd releases the kernel advisory lock). `None` only when the buffer
610    /// was constructed via the test-only `open_with_store` path, which
611    /// bypasses the lock (loom tests do not model the filesystem and would
612    /// otherwise deadlock on a real lock file inside `loom::model`).
613    ///
614    /// Holding the lock as a `File` rather than via `fs4::FileExt::unlock`
615    /// is intentional: the fd-holds-the-lock model is portable (Linux,
616    /// macOS, Windows) and survives panics automatically — the kernel
617    /// closes the fd on process termination, releasing the lock even if
618    /// `Drop` never runs.
619    lock_file: Option<std::fs::File>,
620    /// Result of the open-time mtime capability probe. `true` when the
621    /// filesystem hosting `dir` updates a file's `mtime` on a sub-second
622    /// write-after-write window (ext4/xfs/btrfs/apfs/ntfs-defaults all
623    /// qualify); `false` when the filesystem pins `mtime` to a constant
624    /// (some FUSE mounts, network filesystems with coarse granularity,
625    /// memoised-overlay filesystems) — comparing `0 == 0` would falsely
626    /// confirm cache validity, so we fall back to today's "cache only
627    /// invalidated by in-process mutations" behavior on such filesystems.
628    ///
629    /// See [`probe_mtime_capability`] for the probe sequence and the
630    /// rationale for why a bare stat comparison without the probe is
631    /// unsafe.
632    mtime_supported: bool,
633    /// Last-observed mtime of `dir`, captured alongside every scan_cache
634    /// population. Used by [`scan_segments`](Self::scan_segments) to
635    /// detect external directory manipulation (a backup tool, a manual
636    /// `rm`, an operator quarantining a file) without paying for a full
637    /// readdir on every read. Only consulted when [`mtime_supported`](Self::mtime_supported)
638    /// is `true`; otherwise the cache stays warm until an in-process
639    /// mutation invalidates it.
640    last_dir_mtime: Mutex<Option<std::time::SystemTime>>,
641}
642
643/// `Debug` mirrors the field set of [`BufferStats`] plus the directory path.
644/// It does NOT print the in-memory `unflushed` items (which could be large or
645/// sensitive), so `T` itself is not required to be `Debug`.
646impl<T> std::fmt::Debug for SegmentBuffer<T>
647where
648    T: Serialize + DeserializeOwned + Clone + Send,
649{
650    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651        let stats = self.stats();
652        f.debug_struct("SegmentBuffer")
653            .field("dir", &self.dir)
654            .field("pending_count", &stats.pending_count)
655            .field("latest_sequence", &stats.latest_sequence)
656            .field("head_sequence", &stats.head_sequence)
657            .field("next_sequence", &stats.next_sequence)
658            .field("approx_disk_bytes", &stats.approx_disk_bytes)
659            .field("max_size_bytes", &stats.max_size_bytes)
660            .field("store_pressure", &stats.store_pressure)
661            .finish()
662    }
663}
664
665impl<T> SegmentBuffer<T>
666where
667    T: Serialize + DeserializeOwned + Clone + Send,
668{
669    /// Open (or create) a buffer at `dir`, recovering from any existing
670    /// segment files.
671    ///
672    /// Recovery is **filename-based**: it scans the directory to rebuild
673    /// `head_seq` / `next_seq` and deletes leftover `.tmp` debris. Segment
674    /// *contents* are not read until [`read_from`](Self::read_from), so a
675    /// corrupted segment does not fail here — it fails when read.
676    ///
677    /// If you need the recovery summary (segments found, bytes, head/next seq)
678    /// programmatically, use [`SegmentBuffer::open_with_report`] instead. The
679    /// same data is logged via `tracing::info!` from this call.
680    ///
681    /// # Example
682    ///
683    /// ```
684    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
685    /// use tempfile::tempdir;
686    ///
687    /// let dir = tempdir()?;
688    /// let buf: SegmentBuffer<u64> =
689    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
690    /// # Ok::<(), Box<dyn std::error::Error>>(())
691    /// ```
692    ///
693    /// # Errors
694    ///
695    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
696    pub fn open(dir: impl Into<PathBuf>, config: SegmentConfig) -> Result<Self> {
697        let (buffer, _report) = Self::open_with_report(dir, config)?;
698        Ok(buffer)
699    }
700
701    /// Like [`SegmentBuffer::open`], but also returns a [`RecoveryReport`]
702    /// describing what the recovery scan found on disk.
703    ///
704    /// Useful for operational dashboards or migration tools that need to know
705    /// the on-disk state without re-scanning.
706    ///
707    /// # Example
708    ///
709    /// ```
710    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
711    /// use tempfile::tempdir;
712    ///
713    /// let dir = tempdir()?;
714    /// let (buf, report) =
715    ///     SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
716    /// assert_eq!(report.segment_count, 0); // fresh dir
717    /// assert_eq!(report.head_seq, 0);
718    /// assert_eq!(report.next_seq, 0);
719    /// # Ok::<(), Box<dyn std::error::Error>>(())
720    /// ```
721    ///
722    /// # Errors
723    ///
724    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
725    /// Returns [`SegmentError::Locked`] if another process holds the
726    /// exclusive single-process lock on `<dir>/.segment-buffer.lock`.
727    pub fn open_with_report(
728        dir: impl Into<PathBuf>,
729        config: SegmentConfig,
730    ) -> Result<(Self, RecoveryReport)> {
731        let dir = dir.into();
732        let store: Arc<dyn store::SegmentStore + Send + Sync> =
733            Arc::new(store::RealStore::new(dir.clone()));
734        store.create_dir_all().map_err(|e| e.with_dir())?;
735
736        // Acquire the single-process lock BEFORE any filename parsing or
737        // state publication. A second opener on the same directory would
738        // race on segment filenames, double-deliver, and corrupt
739        // head_seq/next_seq — fail fast with a typed error instead. The
740        // lock is held for the lifetime of the returned SegmentBuffer
741        // (stored in the `lock_file` field); Drop closes the fd, which
742        // releases the kernel advisory lock.
743        let lock_path = dir.join(LOCK_FILE_NAME);
744        let lock_file = std::fs::OpenOptions::new()
745            .create(true)
746            .read(true)
747            .write(true)
748            .truncate(false)
749            .open(&lock_path)
750            .map_err(|source| SegmentError::Io {
751                site: IoSite::Segment(lock_path.clone()),
752                source,
753            })?;
754        if fs4::FileExt::try_lock(&lock_file).is_err() {
755            return Err(SegmentError::Locked { path: lock_path });
756        }
757        Self::open_internal(dir, config, store, Some(lock_file))
758    }
759
760    /// Open (or create) a buffer with a caller-supplied [`SegmentStore`].
761    ///
762    /// Production callers use [`open`](Self::open) (which constructs a
763    /// [`RealStore`] internally AND acquires the single-process flock).
764    /// This constructor exists for loom concurrency tests, which inject a
765    /// mock store backed by `loom::sync::Mutex<HashMap<..>>` so
766    /// `delete_acked` + `append` interleavings can be enumerated without
767    /// modelling the kernel filesystem. It does NOT acquire the flock —
768    /// loom does not model the filesystem, and a real lock file inside
769    /// `loom::model` would deadlock.
770    ///
771    /// Only reachable when the `loom` Cargo feature is enabled. Not part of
772    /// the stable semver surface.
773    ///
774    /// # Errors
775    ///
776    /// Returns [`SegmentError::Io`] if `store.create_dir_all()` fails or
777    /// recovery cannot scan the segment directory.
778    #[cfg(feature = "loom")]
779    pub fn open_with_store(
780        dir: impl Into<PathBuf>,
781        config: SegmentConfig,
782        store: Arc<dyn store::SegmentStore + Send + Sync>,
783    ) -> Result<Self> {
784        let dir = dir.into();
785        let (buffer, _report) = Self::open_internal(dir, config, store, None)?;
786        Ok(buffer)
787    }
788
789    /// Shared constructor used by both the production entry points
790    /// (`open`/`open_with_report`) and the test-only `open_with_store`.
791    /// Owns the invariant that the store is constructed before recovery
792    /// runs, and that `create_dir_all` goes through the store rather than
793    /// `std::fs` directly. `lock_file` is `Some` for production opens
794    /// (the flock was acquired by the caller) and `None` for loom-test
795    /// opens (loom does not model the filesystem).
796    fn open_internal(
797        dir: PathBuf,
798        config: SegmentConfig,
799        store: Arc<dyn store::SegmentStore + Send + Sync>,
800        lock_file: Option<std::fs::File>,
801    ) -> Result<(Self, RecoveryReport)> {
802        // `create_dir_all` was already run by the caller if it owned the
803        // store (production path). When the test harness passes a fresh
804        // store, run it here for symmetry. Idempotent, so a second call is
805        // a no-op.
806        store.create_dir_all().map_err(|e| e.with_dir())?;
807
808        // Allocate the pooled zstd CCtx once, at the configured compression
809        // level. This is the allocation whose per-flush memset was 66% of
810        // `flush` CPU before pooling (flamegraph 2026-07-20). The level is
811        // fixed for the lifetime of the buffer because `SegmentConfig` is
812        // consumed by `open` and immutable thereafter.
813        let compressor = zstd::bulk::Compressor::new(config.compression_level)?;
814        // Allocate the pooled zstd DCtx once — symmetric to the compressor
815        // above. Read paths (`read_from`, `for_each_from`) reuse this DCtx
816        // instead of constructing a fresh one per segment decode.
817        let decompressor = zstd::bulk::Decompressor::new()?;
818
819        // Probe mtime capability: write a sentinel file twice with a short
820        // sleep, and check whether the kernel updated its mtime. On
821        // filesystems that pin mtime to a constant (some FUSE, network
822        // filesystems with coarse granularity), the scan-cache mtime
823        // guard is unsafe (0 == 0 false-positive) and we fall back to
824        // today's "cache invalidated only by in-process mutations"
825        // behavior. The probe runs at open() time so the cost is paid
826        // once. The ~15ms sleep is well within the granularity of every
827        // modern local filesystem (ext4/xfs/btrfs/apfs/ntfs all support
828        // nanosecond mtime); filesystems that fail the probe are exactly
829        // those where the guard would have been unsafe.
830        let mtime_supported = probe_mtime_capability(&dir);
831        let initial_mtime = std::fs::metadata(&dir).and_then(|m| m.modified()).ok();
832
833        let buffer = Self {
834            dir,
835            config,
836            inner: Mutex::new(BufferInner {
837                unflushed: Vec::new(),
838                next_seq: 0,
839                head_seq: 0,
840                last_flush: Instant::now(),
841            }),
842            approx_disk_bytes: std::sync::atomic::AtomicU64::new(0),
843            scan_cache: Mutex::new(None),
844            iteration_in_progress: std::sync::atomic::AtomicBool::new(false),
845            compressor: Mutex::new(compressor),
846            decompressor: Mutex::new(decompressor),
847            store,
848            lock_file,
849            mtime_supported,
850            last_dir_mtime: Mutex::new(initial_mtime),
851        };
852
853        let report = buffer.recover()?;
854        Ok((buffer, report))
855    }
856
857    // -----------------------------------------------------------------------
858    // Public API
859    // -----------------------------------------------------------------------
860
861    /// Append an item to the buffer. Assigns the next sequence number and
862    /// auto-flushes if the batch threshold or interval is reached.
863    ///
864    /// Returns the assigned sequence number. The first append returns `0`,
865    /// and the number increments by 1 for each subsequent append.
866    ///
867    /// # Example
868    ///
869    /// ```
870    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
871    /// use tempfile::tempdir;
872    ///
873    /// let dir = tempdir()?;
874    /// let buf: SegmentBuffer<u64> =
875    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
876    ///
877    /// assert_eq!(buf.append(1)?, 0);
878    /// assert_eq!(buf.append(2)?, 1);
879    /// assert_eq!(buf.append(3)?, 2);
880    /// # Ok::<(), Box<dyn std::error::Error>>(())
881    /// ```
882    #[track_caller]
883    pub fn append(&self, event: T) -> Result<u64> {
884        self.assert_not_reentered("append");
885        let (should_flush, seq) = {
886            let mut inner = self.inner.lock();
887            inner.unflushed.push(event);
888            inner.next_seq += 1;
889            let seq = inner.next_seq - 1;
890
891            let should_flush = self
892                .config
893                .flush_policy
894                .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
895            (should_flush, seq)
896        };
897
898        if should_flush {
899            self.flush()?;
900        }
901
902        Ok(seq)
903    }
904
905    /// Flush buffered items to a segment file. No-op if nothing is buffered.
906    ///
907    /// Flushing is also triggered automatically by [`append`](Self::append)
908    /// according to the configured [`FlushPolicy`] (batch threshold, interval,
909    /// both, or manual). Call this explicitly when you need durability before
910    /// a known threshold, or when using [`FlushPolicy::Manual`].
911    ///
912    /// # Example
913    ///
914    /// ```
915    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
916    /// use tempfile::tempdir;
917    ///
918    /// let dir = tempdir()?;
919    /// let buf: SegmentBuffer<u64> =
920    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
921    /// buf.append(1)?;
922    /// buf.append(2)?;
923    ///
924    /// buf.flush()?; // items now durable on disk
925    /// assert_eq!(buf.pending_count(), 2);
926    /// # Ok::<(), Box<dyn std::error::Error>>(())
927    /// ```
928    #[track_caller]
929    pub fn flush(&self) -> Result<()> {
930        self.assert_not_reentered("flush");
931        let (events, start_seq, end_seq) = {
932            let mut inner = self.inner.lock();
933            inner.last_flush = Instant::now();
934            if inner.unflushed.is_empty() {
935                return Ok(());
936            }
937            let events = std::mem::take(&mut inner.unflushed);
938            let count = events.len() as u64;
939            let end_seq = inner.next_seq - 1;
940            let start_seq = end_seq + 1 - count;
941            (events, start_seq, end_seq)
942        };
943
944        let compressed_len = self.write_segment(start_seq, end_seq, &events)?;
945
946        // approx_disk_bytes is now an AtomicU64, so flush() no longer needs
947        // to re-acquire the mutex just to bump one u64.
948        self.approx_disk_bytes
949            .fetch_add(compressed_len, std::sync::atomic::Ordering::Relaxed);
950        // A new segment file invalidates the directory-scan cache.
951        self.invalidate_scan_cache();
952
953        debug!(
954            path = self.segment_path(start_seq, end_seq).display().to_string(),
955            seq = start_seq,
956            end_seq,
957            count = events.len(),
958            bytes = compressed_len,
959            "Flushed segment"
960        );
961        Ok(())
962    }
963
964    /// Read up to `limit` items starting from `start_seq` (inclusive).
965    ///
966    /// Reads from both on-disk segment files and in-memory pending items.
967    /// Items are returned in ascending sequence order.
968    ///
969    /// Passing `limit = 0` returns an empty `Vec` without scanning.
970    ///
971    /// # Example
972    ///
973    /// ```
974    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
975    /// use tempfile::tempdir;
976    ///
977    /// let dir = tempdir()?;
978    /// let buf: SegmentBuffer<u64> =
979    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
980    /// buf.append(10)?;
981    /// buf.append(20)?;
982    /// buf.append(30)?;
983    /// buf.flush()?;
984    ///
985    /// let items = buf.read_from(0, 100)?;
986    /// assert_eq!(items, vec![10, 20, 30]);
987    ///
988    /// // start_seq skips already-read items:
989    /// let tail = buf.read_from(2, 100)?;
990    /// assert_eq!(tail, vec![30]);
991    /// # Ok::<(), Box<dyn std::error::Error>>(())
992    /// ```
993    #[track_caller]
994    pub fn read_from(&self, start_seq: u64, limit: usize) -> Result<Vec<T>> {
995        self.assert_not_reentered("read_from");
996        if limit == 0 {
997            return Ok(Vec::new());
998        }
999
1000        let mut result: Vec<T> = Vec::with_capacity(limit.min(1024));
1001
1002        // Phase 1: read from on-disk segments.
1003        let segments = self.scan_segments()?;
1004        for seg in &segments {
1005            if result.len() >= limit {
1006                break;
1007            }
1008            if seg.end < start_seq {
1009                continue;
1010            }
1011
1012            let events = self.read_segment(*seg)?;
1013            let skip = if seg.start < start_seq {
1014                (start_seq - seg.start) as usize
1015            } else {
1016                0
1017            };
1018
1019            for event in events.into_iter().skip(skip) {
1020                if result.len() >= limit {
1021                    break;
1022                }
1023                result.push(event);
1024            }
1025        }
1026
1027        // Phase 2: read from in-memory pending events.
1028        if result.len() < limit {
1029            let inner = self.inner.lock();
1030            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
1031            for (i, event) in inner.unflushed.iter().enumerate() {
1032                let seq = pending_start + i as u64;
1033                if seq < start_seq {
1034                    continue;
1035                }
1036                if result.len() >= limit {
1037                    break;
1038                }
1039                result.push(event.clone());
1040            }
1041        }
1042
1043        Ok(result)
1044    }
1045
1046    /// Lending-iterator counterpart to [`read_from`](Self::read_from): invoke
1047    /// `f(seq, item)` for up to `limit` items starting at `start_seq`, without
1048    /// materialising them into a `Vec<T>`.
1049    ///
1050    /// This avoids the per-item `Clone` that [`read_from`](Self::read_from)
1051    /// pays for in-memory pending items. On-disk segments still deserialize
1052    /// into a temporary `Vec<T>` per segment (the on-disk format is bytes, not
1053    /// `T`), but items are passed to `f` by reference rather than being
1054    /// re-collected.
1055    ///
1056    /// Returns the number of items the callback was invoked for.
1057    ///
1058    /// # Performance
1059    ///
1060    /// Micro-benchmarked in `benches/bench_read_vs_for_each.rs` against
1061    /// in-memory pending items (no segment files):
1062    ///
1063    /// | Items | `read_from` | `for_each_from` | Speedup |
1064    /// |-------|-------------|-----------------|---------|
1065    /// | 1,000 | ~26 µs      | ~1.2 µs         | ~21×    |
1066    /// | 10,000| ~200 µs     | ~10 µs          | ~20×    |
1067    ///
1068    /// The speedup shrinks toward zero once on-disk segments dominate, because
1069    /// both paths pay the same CBOR+zstd+cipher decode cost per segment — the
1070    /// clone saving only applies to the in-memory tail.
1071    ///
1072    /// # Re-entrancy contract
1073    ///
1074    /// The buffer mutex is held across `f` while iterating the in-memory
1075    /// pending items. Calling **any** other `&self` method on `SegmentBuffer`
1076    /// from inside `f` would deadlock (`parking_lot::Mutex` is not reentrant).
1077    /// To make this footgun impossible to hit silently, every other method
1078    /// asserts it is not being re-entered from inside a `for_each_from`
1079    /// callback and **panics with a clear message** if it is. The callback
1080    /// receives only `(seq, &T)`, which gives no way to reach the buffer, but
1081    /// a closure that captures a clone of the `Arc<SegmentBuffer<T>>` can
1082    /// still attempt re-entry — and will now get an immediate, diagnosable
1083    /// panic instead of a silent hang.
1084    ///
1085    /// # Example
1086    ///
1087    /// ```
1088    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1089    /// use tempfile::tempdir;
1090    ///
1091    /// let dir = tempdir()?;
1092    /// let buf: SegmentBuffer<u64> =
1093    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1094    /// for i in 0..5u64 {
1095    ///     buf.append(i * 10)?;
1096    /// }
1097    /// buf.flush()?;
1098    ///
1099    /// let mut sum = 0u64;
1100    /// let count = buf.for_each_from(0, 100, |_seq, item| { sum += *item; })?;
1101    /// assert_eq!(count, 5);
1102    /// assert_eq!(sum, 0 + 10 + 20 + 30 + 40);
1103    /// # Ok::<(), Box<dyn std::error::Error>>(())
1104    /// ```
1105    pub fn for_each_from<F>(&self, start_seq: u64, limit: usize, mut f: F) -> Result<usize>
1106    where
1107        F: FnMut(u64, &T),
1108    {
1109        if limit == 0 {
1110            return Ok(0);
1111        }
1112
1113        // Mark iteration in progress for the entire call. Every other locking
1114        // method asserts the flag is false and panics with a clear message,
1115        // converting the silent deadlock (parking_lot::Mutex is not reentrant)
1116        // into an immediate, diagnosable failure. The guard clears the flag on
1117        // scope exit, including during panic unwinding from `f`.
1118        self.iteration_in_progress
1119            .store(true, std::sync::atomic::Ordering::Relaxed);
1120        let _guard = IterationGuard(&self.iteration_in_progress);
1121
1122        let mut visited = 0usize;
1123
1124        // Phase 1: on-disk segments. Items are still deserialized into a per-
1125        // segment Vec<T>, but each is handed to f by reference rather than
1126        // being re-collected into the caller's Vec.
1127        let segments = self.scan_segments()?;
1128        for seg in &segments {
1129            if visited >= limit {
1130                break;
1131            }
1132            if seg.end < start_seq {
1133                continue;
1134            }
1135
1136            let events = self.read_segment(*seg)?;
1137            let skip = if seg.start < start_seq {
1138                (start_seq - seg.start) as usize
1139            } else {
1140                0
1141            };
1142
1143            for (offset, event) in events.iter().enumerate().skip(skip) {
1144                if visited >= limit {
1145                    break;
1146                }
1147                let seq = seg.start + offset as u64;
1148                f(seq, event);
1149                visited += 1;
1150            }
1151        }
1152
1153        // Phase 2: in-memory pending items. Here the lending pattern wins:
1154        // the items are borrowed in place under the lock, with zero clones.
1155        if visited < limit {
1156            let inner = self.inner.lock();
1157            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
1158            for (i, event) in inner.unflushed.iter().enumerate() {
1159                if visited >= limit {
1160                    break;
1161                }
1162                let seq = pending_start + i as u64;
1163                if seq < start_seq {
1164                    continue;
1165                }
1166                f(seq, event);
1167                visited += 1;
1168            }
1169        }
1170
1171        Ok(visited)
1172    }
1173
1174    /// Delete all on-disk segment files whose items are fully covered by
1175    /// `acked_seq`.
1176    ///
1177    /// A segment is deleted when its `end_seq <= acked_seq`. Returns the number
1178    /// of segment files removed.
1179    ///
1180    /// # Example
1181    ///
1182    /// ```
1183    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1184    /// use tempfile::tempdir;
1185    ///
1186    /// let dir = tempdir()?;
1187    /// let buf: SegmentBuffer<u64> =
1188    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1189    /// for i in 0..5u64 {
1190    ///     buf.append(i)?;
1191    /// }
1192    /// buf.flush()?;
1193    ///
1194    /// // Consumer has processed sequence 0..=4; acknowledge them:
1195    /// let removed = buf.delete_acked(4)?;
1196    /// assert_eq!(removed, 1); // one segment file deleted
1197    /// assert_eq!(buf.pending_count(), 0);
1198    /// # Ok::<(), Box<dyn std::error::Error>>(())
1199    /// ```
1200    ///
1201    /// # Limitation
1202    ///
1203    /// Acknowledgement only removes **flushed** segment files. Items still held
1204    /// in the in-memory pending batch have no segment file to delete, so they
1205    /// remain readable (and counted by [`SegmentBuffer::pending_count`]) until
1206    /// they are flushed and acknowledged in a later call. `head_seq` is clamped
1207    /// so it never advances past the pending window, keeping the backlog count
1208    /// honest.
1209    #[track_caller]
1210    pub fn delete_acked(&self, acked_seq: u64) -> Result<usize> {
1211        self.assert_not_reentered("delete_acked");
1212        let segments = self.scan_segments()?;
1213        let mut deleted = 0;
1214        let mut freed_bytes: u64 = 0;
1215        let mut new_head = None;
1216
1217        for seg in &segments {
1218            if seg.end <= acked_seq {
1219                let path = self.segment_path(seg.start, seg.end);
1220                let file_bytes = self.store.segment_size(*seg);
1221                freed_bytes += file_bytes;
1222                // remove_segment is idempotent on NotFound so concurrent
1223                // delete_acked calls do not race on the same segment file.
1224                // Returns true iff THIS call actually removed the file.
1225                if self.store.remove_segment(*seg)? {
1226                    deleted += 1;
1227                    debug!(
1228                        path = path.display().to_string(),
1229                        seq = seg.start,
1230                        end_seq = seg.end,
1231                        bytes = file_bytes,
1232                        "Deleted acked segment"
1233                    );
1234                }
1235            } else if new_head.is_none() {
1236                new_head = Some(seg.start);
1237            }
1238        }
1239
1240        // Subtract the freed bytes atomically; the lock is still needed for
1241        // head_seq, but approx_disk_bytes can update independently.
1242        self.approx_disk_bytes
1243            .fetch_sub(freed_bytes, std::sync::atomic::Ordering::Relaxed);
1244        // Deleted segment files invalidate the directory-scan cache.
1245        self.invalidate_scan_cache();
1246
1247        {
1248            let mut inner = self.inner.lock();
1249            // `head_seq` tracks the oldest unacked sequence. Clamp it to the
1250            // start of the in-memory pending window: items still waiting to be
1251            // flushed cannot be acknowledged (there is no segment file to
1252            // delete), so head_seq must not advance past them. Without this
1253            // clamp, acknowledging past a buffer that still holds unflushed
1254            // items would make `pending_count` under-report the real backlog.
1255            let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
1256            inner.head_seq = new_head.unwrap_or(inner.next_seq).min(pending_start);
1257        }
1258
1259        if deleted > 0 {
1260            info!(
1261                path = self.dir.display().to_string(),
1262                deleted,
1263                bytes = freed_bytes,
1264                seq = acked_seq,
1265                "Deleted acked segments"
1266            );
1267        }
1268
1269        Ok(deleted)
1270    }
1271
1272    /// The highest sequence number assigned (or 0 if buffer is empty).
1273    ///
1274    /// # Example
1275    ///
1276    /// ```
1277    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1278    /// use tempfile::tempdir;
1279    ///
1280    /// let dir = tempdir()?;
1281    /// let buf: SegmentBuffer<u64> =
1282    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1283    ///
1284    /// assert_eq!(buf.latest_sequence(), 0);
1285    /// buf.append(7)?;
1286    /// assert_eq!(buf.latest_sequence(), 0);
1287    /// buf.append(8)?;
1288    /// assert_eq!(buf.latest_sequence(), 1);
1289    /// # Ok::<(), Box<dyn std::error::Error>>(())
1290    /// ```
1291    #[must_use = "the sequence number is meaningless if discarded"]
1292    #[track_caller]
1293    pub fn latest_sequence(&self) -> u64 {
1294        self.assert_not_reentered("latest_sequence");
1295        let inner = self.inner.lock();
1296        if inner.next_seq == 0 {
1297            0
1298        } else {
1299            inner.next_seq - 1
1300        }
1301    }
1302
1303    /// Total items waiting in the buffer (on-disk + in-memory pending).
1304    ///
1305    /// Equivalent to `latest_sequence() - head_seq + 1` when non-empty, 0 when
1306    /// empty. Decreases as [`delete_acked`](Self::delete_acked) removes files.
1307    ///
1308    /// # Example
1309    ///
1310    /// ```
1311    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1312    /// use tempfile::tempdir;
1313    ///
1314    /// let dir = tempdir()?;
1315    /// let buf: SegmentBuffer<u64> =
1316    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1317    ///
1318    /// assert_eq!(buf.pending_count(), 0);
1319    /// buf.append(1)?;
1320    /// buf.append(2)?;
1321    /// assert_eq!(buf.pending_count(), 2);
1322    /// buf.flush()?;
1323    /// assert_eq!(buf.pending_count(), 2); // still pending until acked
1324    /// buf.delete_acked(1)?;
1325    /// assert_eq!(buf.pending_count(), 0);
1326    /// # Ok::<(), Box<dyn std::error::Error>>(())
1327    /// ```
1328    #[must_use = "the backlog size is meaningless if discarded"]
1329    #[track_caller]
1330    pub fn pending_count(&self) -> u64 {
1331        self.assert_not_reentered("pending_count");
1332        let inner = self.inner.lock();
1333        inner.next_seq.saturating_sub(inner.head_seq)
1334    }
1335
1336    /// Standard [`len`](#method.len) alias for [`pending_count`](Self::pending_count).
1337    ///
1338    /// Provided so `SegmentBuffer` reads like a normal collection at the call
1339    /// site (`buf.len()`, `buf.is_empty()`). Same value as `pending_count()`,
1340    /// kept as `u64` because the buffer is proven beyond `usize::MAX` on
1341    /// 32-bit targets (597M+ events in monitor365).
1342    ///
1343    /// # Example
1344    ///
1345    /// ```
1346    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1347    /// use tempfile::tempdir;
1348    ///
1349    /// let dir = tempdir()?;
1350    /// let buf: SegmentBuffer<u64> =
1351    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1352    /// assert!(buf.is_empty());
1353    /// buf.append(7)?;
1354    /// assert_eq!(buf.len(), 1);
1355    /// assert!(!buf.is_empty());
1356    /// # Ok::<(), Box<dyn std::error::Error>>(())
1357    /// ```
1358    #[must_use = "the backlog size is meaningless if discarded"]
1359    pub fn len(&self) -> u64 {
1360        self.pending_count()
1361    }
1362
1363    /// `true` when there are no items waiting in the buffer (on-disk or
1364    /// in-memory). Equivalent to `pending_count() == 0`.
1365    ///
1366    /// # Example
1367    ///
1368    /// ```
1369    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1370    /// use tempfile::tempdir;
1371    ///
1372    /// let dir = tempdir()?;
1373    /// let buf: SegmentBuffer<u64> =
1374    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1375    /// assert!(buf.is_empty());
1376    /// # Ok::<(), Box<dyn std::error::Error>>(())
1377    /// ```
1378    #[must_use = "the emptiness flag is meaningless if discarded"]
1379    pub fn is_empty(&self) -> bool {
1380        self.pending_count() == 0
1381    }
1382
1383    /// Disk usage pressure as a value between 0.0 and 1.0.
1384    ///
1385    /// Use this to implement your own admission/backpressure policy (e.g.
1386    /// reject low-priority items above 0.90, reject standard items above 0.95).
1387    /// Returns 0.0 when `max_size_bytes == 0` (limit disabled).
1388    ///
1389    /// # Example
1390    ///
1391    /// ```
1392    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1393    /// use tempfile::tempdir;
1394    ///
1395    /// let dir = tempdir()?;
1396    /// let mut cfg = SegmentConfig::default();
1397    /// cfg.max_size_bytes = 1000; // tiny limit so pressure is observable
1398    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), cfg)?;
1399    ///
1400    /// assert!(buf.store_pressure() < 0.1);
1401    /// # Ok::<(), Box<dyn std::error::Error>>(())
1402    /// ```
1403    #[must_use = "the pressure value is meaningless if discarded"]
1404    pub fn store_pressure(&self) -> f32 {
1405        // store_pressure only needs approx_disk_bytes + max_size_bytes —
1406        // neither requires the mutex. Read the atomic directly to avoid
1407        // contending with append/flush.
1408        if self.config.max_size_bytes == 0 {
1409            return 0.0;
1410        }
1411        let bytes = self
1412            .approx_disk_bytes
1413            .load(std::sync::atomic::Ordering::Relaxed);
1414        (bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1415    }
1416
1417    /// True when disk usage exceeds 90% of the configured limit.
1418    ///
1419    /// Convenience wrapper around `store_pressure() > 0.9`.
1420    ///
1421    /// # Example
1422    ///
1423    /// ```
1424    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1425    /// use tempfile::tempdir;
1426    ///
1427    /// let dir = tempdir()?;
1428    /// let buf: SegmentBuffer<u64> =
1429    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1430    ///
1431    /// assert!(!buf.is_overloaded());
1432    /// # Ok::<(), Box<dyn std::error::Error>>(())
1433    /// ```
1434    #[must_use = "the overload flag is meaningless if discarded"]
1435    pub fn is_overloaded(&self) -> bool {
1436        self.store_pressure() > 0.9
1437    }
1438
1439    /// Capture a consistent snapshot of buffer state under a single lock.
1440    ///
1441    /// Cheaper and more consistent than calling
1442    /// [`pending_count`](Self::pending_count),
1443    /// [`latest_sequence`](Self::latest_sequence),
1444    /// [`store_pressure`](Self::store_pressure) etc. individually (which each
1445    /// take the mutex and could observe a flush/delete between calls).
1446    ///
1447    /// # Performance
1448    ///
1449    /// Micro-benchmarked in `benches/bench_stats.rs` (run with
1450    /// `cargo bench --bench bench_stats --features encryption`):
1451    ///
1452    /// | Operation                                  | Measured time (median, typical run) |
1453    /// |--------------------------------------------|--------------------------------------|
1454    /// | `stats()` (single lock, 7-field snapshot)  | ~12 ns                               |
1455    /// | 3 individual accessors (`pending_count` + `latest_sequence` + `store_pressure`) | ~31 ns |
1456    ///
1457    /// So `stats()` is roughly **2.5× cheaper than 3 individual accessors**
1458    /// while also being atomic — torn reads between calls are impossible.
1459    /// Numbers are from the benchmark machine and fluctuate with hardware;
1460    /// the relative ratio is the durable claim.
1461    ///
1462    /// # Example
1463    ///
1464    /// ```
1465    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1466    /// use tempfile::tempdir;
1467    ///
1468    /// let dir = tempdir()?;
1469    /// let buf: SegmentBuffer<u64> =
1470    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1471    /// buf.append(1)?;
1472    /// buf.append(2)?;
1473    ///
1474    /// let snapshot = buf.stats();
1475    /// assert_eq!(snapshot.pending_count, 2);
1476    /// assert_eq!(snapshot.next_sequence, 2);
1477    /// assert!(snapshot.store_pressure < 0.01);
1478    /// # Ok::<(), Box<dyn std::error::Error>>(())
1479    /// ```
1480    #[must_use = "the snapshot is meaningless if discarded"]
1481    #[track_caller]
1482    pub fn stats(&self) -> BufferStats {
1483        self.assert_not_reentered("stats");
1484        let inner = self.inner.lock();
1485        let pending_count = inner.next_seq.saturating_sub(inner.head_seq);
1486        let latest_sequence = if inner.next_seq == 0 {
1487            0
1488        } else {
1489            inner.next_seq - 1
1490        };
1491        // Load the atomic OUTSIDE the mutex's critical section logic — the
1492        // value is approximate by design, so a torn read between this load
1493        // and the inner.lock() is acceptable.
1494        let approx_disk_bytes = self
1495            .approx_disk_bytes
1496            .load(std::sync::atomic::Ordering::Relaxed);
1497        let store_pressure = if self.config.max_size_bytes == 0 {
1498            0.0
1499        } else {
1500            (approx_disk_bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1501        };
1502        BufferStats {
1503            pending_count,
1504            latest_sequence,
1505            head_sequence: inner.head_seq,
1506            next_sequence: inner.next_seq,
1507            approx_disk_bytes,
1508            max_size_bytes: self.config.max_size_bytes,
1509            store_pressure,
1510        }
1511    }
1512
1513    /// The directory this buffer reads from and writes segment files to.
1514    ///
1515    /// Useful for operators that need to inspect, archive, or quarantine the
1516    /// segment directory without parsing it out of [`Debug`](std::fmt::Debug).
1517    ///
1518    /// # Example
1519    ///
1520    /// ```
1521    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1522    /// use tempfile::tempdir;
1523    ///
1524    /// let dir = tempdir()?;
1525    /// let buf: SegmentBuffer<u64> =
1526    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1527    /// assert_eq!(buf.path(), dir.path());
1528    /// # Ok::<(), Box<dyn std::error::Error>>(())
1529    /// ```
1530    #[must_use = "the path is meaningless if discarded"]
1531    pub fn path(&self) -> &std::path::Path {
1532        &self.dir
1533    }
1534
1535    /// The [`SegmentConfig`] this buffer was opened with.
1536    ///
1537    /// Returned by reference so callers can inspect the flush policy, disk
1538    /// ceiling, compression level, and cipher presence without re-deriving
1539    /// them. The config is immutable for the lifetime of the buffer.
1540    ///
1541    /// # Example
1542    ///
1543    /// ```
1544    /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
1545    /// use tempfile::tempdir;
1546    ///
1547    /// let dir = tempdir()?;
1548    /// let config = SegmentConfig::builder()
1549    ///     .flush_at_batch_size(128)
1550    ///     .build();
1551    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
1552    /// match &buf.config().flush_policy {
1553    ///     FlushPolicy::Batch(n) => println!("flushing at {n} items"),
1554    ///     _ => {}
1555    /// }
1556    /// # Ok::<(), Box<dyn std::error::Error>>(())
1557    /// ```
1558    #[must_use = "the config is meaningless if discarded"]
1559    pub fn config(&self) -> &SegmentConfig {
1560        &self.config
1561    }
1562
1563    /// Re-stat the segment directory and store the authoritative total as
1564    /// [`BufferStats::approx_disk_bytes`].
1565    ///
1566    /// [`BufferStats::approx_disk_bytes`] is updated incrementally on every
1567    /// flush/delete/recover, so it is accurate as long as only this buffer
1568    /// touches the directory. If an external process (backup, compaction,
1569    /// manual cleanup) adds or removes segment files, the cached value drifts.
1570    /// This method recomputes it from a directory scan.
1571    ///
1572    /// Returns the new total so callers can observe the delta without a
1573    /// second call to [`stats`](Self::stats).
1574    ///
1575    /// # Example
1576    ///
1577    /// ```
1578    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1579    /// use tempfile::tempdir;
1580    ///
1581    /// let dir = tempdir()?;
1582    /// let buf: SegmentBuffer<u64> =
1583    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1584    /// buf.append(1)?;
1585    /// buf.flush()?;
1586    ///
1587    /// // Simulate an external process truncating a segment file to zero bytes.
1588    /// for entry in std::fs::read_dir(dir.path())? {
1589    ///     let _ = std::fs::write(entry?.path(), b"");
1590    /// }
1591    ///
1592    /// let synced = buf.sync_disk_bytes()?;
1593    /// assert_eq!(synced, 0, "external truncation should be reflected");
1594    /// # Ok::<(), Box<dyn std::error::Error>>(())
1595    /// ```
1596    ///
1597    /// # Errors
1598    ///
1599    /// Returns [`SegmentError::Io`] if the directory cannot be read.
1600    #[track_caller]
1601    pub fn sync_disk_bytes(&self) -> Result<u64> {
1602        self.assert_not_reentered("sync_disk_bytes");
1603        let segments = self.scan_segments()?;
1604        let total: u64 = segments.iter().map(|s| self.store.segment_size(*s)).sum();
1605        self.approx_disk_bytes
1606            .store(total, std::sync::atomic::Ordering::Relaxed);
1607        Ok(total)
1608    }
1609
1610    /// Append a batch of items under a single lock acquisition.
1611    ///
1612    /// Each item receives the next contiguous sequence number. Returns the
1613    /// last sequence number assigned (matching the contract of
1614    /// [`append`](Self::append)); the full range is
1615    /// `[last - count + 1, last]` where `count` is the number of items the
1616    /// iterator yielded.
1617    ///
1618    /// # Batch vs streaming semantics
1619    ///
1620    /// All items are accumulated under a single lock acquisition, then the
1621    /// flush policy is checked **once** at the end. This gives true atomic
1622    /// batch semantics: either the entire batch lands in the buffer or the
1623    /// error propagates. Callers who want per-item auto-flush semantics
1624    /// (flush at every `batch_size` threshold) should call
1625    /// [`append`](Self::append) in a loop instead — `append_all` is
1626    /// optimized for the "load this batch atomically" use case and avoids
1627    /// paying the lock-acquisition cost per item.
1628    ///
1629    /// # Example
1630    ///
1631    /// ```
1632    /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
1633    /// use tempfile::tempdir;
1634    ///
1635    /// let dir = tempdir()?;
1636    /// let config = SegmentConfig::builder()
1637    ///     .flush_policy(FlushPolicy::Manual)
1638    ///     .build();
1639    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
1640    ///
1641    /// let last = buf.append_all([10u64, 20, 30, 40])?;
1642    /// assert_eq!(last, 3); // 0-based: items got seqs 0, 1, 2, 3
1643    /// assert_eq!(buf.pending_count(), 4);
1644    /// # Ok::<(), Box<dyn std::error::Error>>(())
1645    /// ```
1646    ///
1647    /// # Errors
1648    ///
1649    /// Returns [`SegmentError::Io`] if a flush triggered by the batch fails.
1650    #[track_caller]
1651    pub fn append_all<I>(&self, items: I) -> Result<u64>
1652    where
1653        I: IntoIterator<Item = T>,
1654    {
1655        self.assert_not_reentered("append_all");
1656        let (should_flush, last_seq, count) = {
1657            let mut inner = self.inner.lock();
1658            let mut count = 0u64;
1659            let mut last_seq = inner.next_seq.saturating_sub(1);
1660            for item in items {
1661                inner.unflushed.push(item);
1662                inner.next_seq = inner.next_seq.wrapping_add(1);
1663                last_seq = inner.next_seq - 1;
1664                count += 1;
1665            }
1666            if count == 0 {
1667                // Empty iterator: no-op, return current last seq (or 0).
1668                return Ok(inner.next_seq.saturating_sub(1));
1669            }
1670            let should_flush = self
1671                .config
1672                .flush_policy
1673                .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
1674            (should_flush, last_seq, count)
1675        };
1676        debug_assert!(count > 0);
1677        if should_flush {
1678            self.flush()?;
1679        }
1680        Ok(last_seq)
1681    }
1682
1683    /// Owned-item iterator over buffer contents starting at `start_seq`.
1684    ///
1685    /// Equivalent to [`read_from`](Self::read_from) but yields `(seq, item)`
1686    /// pairs one at a time so callers can write `for (seq, item) in
1687    /// buf.iter_from(start, limit)?` and chain standard
1688    /// [`Iterator`] combinators (`.take`, `.filter`, `.map`, …).
1689    ///
1690    /// This is a *materialising* iterator: items are loaded eagerly up to
1691    /// `limit` (memory cost `O(limit)`). For a *lending* iterator that
1692    /// passes in-memory items by reference without cloning, use
1693    /// [`for_each_from`](Self::for_each_from) — that variant is ~20× faster
1694    /// on the in-memory tail but takes a closure instead of returning an
1695    /// `Iterator`. The two coexist because no stable-Rust `Iterator`
1696    /// trait can currently express "yield `&T` from `&mut self`" without
1697    /// pre-collecting.
1698    ///
1699    /// # Re-entrancy contract
1700    ///
1701    /// The iterator borrows the buffer for `'a`. Drop the iterator before
1702    /// calling any other `&self` method on the same buffer; if the
1703    /// iterator is alive across a `flush` / `append` / `delete_acked`
1704    /// call, that call will panic with a clear message (same contract as
1705    /// [`for_each_from`](Self::for_each_from)). The simplest pattern is
1706    /// `for item in buf.iter_from(..)? { ... }` — the `for` loop drops the
1707    /// iterator at the end of the block.
1708    ///
1709    /// # Example
1710    ///
1711    /// ```
1712    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1713    /// use tempfile::tempdir;
1714    ///
1715    /// let dir = tempdir()?;
1716    /// let buf: SegmentBuffer<u64> =
1717    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1718    /// for i in 0..5u64 { buf.append(i * 10)?; }
1719    /// buf.flush()?;
1720    ///
1721    /// // `for` loop with owned items + seq numbers:
1722    /// let mut seen = Vec::new();
1723    /// for (seq, item) in buf.iter_from(0, 100)? {
1724    ///     seen.push((seq, item));
1725    /// }
1726    /// assert_eq!(seen, vec![
1727    ///     (0, 0), (1, 10), (2, 20), (3, 30), (4, 40),
1728    /// ]);
1729    /// # Ok::<(), Box<dyn std::error::Error>>(())
1730    /// ```
1731    ///
1732    /// # Errors
1733    ///
1734    /// Returns [`SegmentError`] if the directory scan or any segment decode
1735    /// fails.
1736    #[track_caller]
1737    pub fn iter_from(&self, start_seq: u64, limit: usize) -> Result<SegmentIter<'_, T>> {
1738        self.assert_not_reentered("iter_from");
1739        if limit == 0 {
1740            return Ok(SegmentIter {
1741                inner: Vec::new().into_iter(),
1742                _phantom: std::marker::PhantomData,
1743            });
1744        }
1745        let items = self.read_from(start_seq, limit)?;
1746        let indexed: Vec<(u64, T)> = items
1747            .into_iter()
1748            .enumerate()
1749            .map(|(i, item)| (start_seq + i as u64, item))
1750            .collect();
1751        Ok(SegmentIter {
1752            inner: indexed.into_iter(),
1753            _phantom: std::marker::PhantomData,
1754        })
1755    }
1756
1757    // -----------------------------------------------------------------------
1758    // Internal helpers
1759    // -----------------------------------------------------------------------
1760
1761    /// Panic with a clear message if a `for_each_from` callback is currently
1762    /// re-entering the buffer. The alternative is a silent deadlock
1763    /// (`parking_lot::Mutex` is not reentrant), so an explicit panic is
1764    /// strictly better for diagnosability.
1765    #[track_caller]
1766    fn assert_not_reentered(&self, method: &'static str) {
1767        if self
1768            .iteration_in_progress
1769            .load(std::sync::atomic::Ordering::Relaxed)
1770        {
1771            panic!(
1772                "{method}: cannot call from within a for_each_from callback \
1773                 (the buffer mutex is held; re-entry would deadlock)"
1774            );
1775        }
1776    }
1777
1778    fn recover(&self) -> Result<RecoveryReport> {
1779        let removed_tmp_files = self.store.clean_tmp()?;
1780
1781        let segments = self.scan_segments()?;
1782
1783        // All store access (sizing each segment) happens BEFORE the mutex is
1784        // taken. The lock is held only long enough to publish the rebuilt
1785        // in-memory state, honouring the invariant that the mutex is never
1786        // held across I/O.
1787        let total_bytes: u64 = segments.iter().map(|s| self.store.segment_size(*s)).sum();
1788
1789        let (head_seq, next_seq) = match (segments.first(), segments.last()) {
1790            (Some(first), Some(last)) => (first.start, last.end + 1),
1791            _ => (0, 0),
1792        };
1793
1794        let segment_count = segments.len();
1795        {
1796            let mut inner = self.inner.lock();
1797            inner.head_seq = head_seq;
1798            inner.next_seq = next_seq;
1799        }
1800        // Store the recovered disk-bytes total into the atomic directly.
1801        self.approx_disk_bytes
1802            .store(total_bytes, std::sync::atomic::Ordering::Relaxed);
1803        // Recovery just scanned the directory; populate the cache so the
1804        // first read_from/delete_acked after open does not re-scan.
1805        *self.scan_cache.lock() = Some(segments.clone());
1806
1807        info!(
1808            path = self.dir.display().to_string(),
1809            segments = segment_count,
1810            seq = head_seq,
1811            end_seq = next_seq,
1812            bytes = total_bytes,
1813            removed_tmp = removed_tmp_files,
1814            "Segment buffer recovered"
1815        );
1816
1817        Ok(RecoveryReport {
1818            segment_count,
1819            head_seq,
1820            next_seq,
1821            disk_bytes: total_bytes,
1822            removed_tmp_files,
1823        })
1824    }
1825
1826    fn write_segment(&self, start: u64, end: u64, events: &[T]) -> Result<u64> {
1827        let path = self.segment_path(start, end);
1828        let range = segment::SegmentRange::new(start, end);
1829        // Lock the pooled compressor for the duration of the encode. The
1830        // mutex is uncontended in practice (see field doc) and the lock is
1831        // NOT held across the store's `write_atomic` call below —
1832        // `encode_segment` returns bytes before any I/O begins.
1833        let mut compressor = self.compressor.lock();
1834        let bytes = segment::encode_segment(
1835            self.config.cipher.as_deref(),
1836            &mut compressor,
1837            &path,
1838            events,
1839        )?;
1840        drop(compressor);
1841        self.store
1842            .write_atomic(range, &bytes, self.config.durability)
1843            .map_err(|e| e.with_path(&path))
1844    }
1845
1846    fn read_segment(&self, seg: segment::SegmentRange) -> Result<Vec<T>> {
1847        let path = self.segment_path(seg.start, seg.end);
1848        let raw = self.store.read_bytes(seg).map_err(|e| e.with_path(&path))?;
1849        let mut decompressor = self.decompressor.lock();
1850        segment::decode_segment(
1851            self.config.cipher.as_deref(),
1852            &mut decompressor,
1853            &raw,
1854            &path,
1855        )
1856        .map_err(|e| e.with_path(&path))
1857    }
1858
1859    fn scan_segments(&self) -> Result<Vec<segment::SegmentRange>> {
1860        // Cache hit: clone under the cache lock and return — UNLESS the
1861        // directory mtime has moved since the cache was populated (which
1862        // signals an external mutation: backup tool, manual rm, operator
1863        // quarantine, etc.). The mtime guard is only consulted when the
1864        // open-time capability probe confirmed the filesystem actually
1865        // updates mtime — on filesystems that pin mtime to a constant,
1866        // comparing 0 == 0 would falsely confirm validity, so we skip the
1867        // check entirely on those.
1868        {
1869            let cache = self.scan_cache.lock();
1870            if let Some(ref segments) = *cache {
1871                if !self.mtime_supported || !self.dir_mtime_changed() {
1872                    return Ok(segments.clone());
1873                }
1874                // mtime moved → fall through to re-scan, replacing the cache.
1875            }
1876        }
1877        // Cache miss: scan via the store, store, return.
1878        let segments = self
1879            .store
1880            .scan()
1881            .map_err(|e| e.with_dir())
1882            .map_err(|e| e.with_path(&self.dir))?;
1883        // Refresh the cached dir mtime alongside the cache population so
1884        // future reads can detect external mutations.
1885        let fresh_mtime = std::fs::metadata(&self.dir).and_then(|m| m.modified()).ok();
1886        let mut cache = self.scan_cache.lock();
1887        *cache = Some(segments.clone());
1888        *self.last_dir_mtime.lock() = fresh_mtime;
1889        Ok(segments)
1890    }
1891
1892    /// Stat the directory's mtime and compare against the last-cached
1893    /// value. `true` means the directory was touched externally and the
1894    /// scan cache should be invalidated. Cheap (`stat` is one syscall;
1895    /// `readdir` is many).
1896    fn dir_mtime_changed(&self) -> bool {
1897        let current = match std::fs::metadata(&self.dir).and_then(|m| m.modified()) {
1898            Ok(t) => t,
1899            Err(_) => return true, // directory unreadable → safer to re-scan
1900        };
1901        let cached = *self.last_dir_mtime.lock();
1902        match cached {
1903            Some(prev) => prev != current,
1904            None => true,
1905        }
1906    }
1907
1908    /// Invalidate the scan cache. Called by every on-disk mutation
1909    /// (`flush`, `delete_acked`, `recover`).
1910    fn invalidate_scan_cache(&self) {
1911        let mut cache = self.scan_cache.lock();
1912        *cache = None;
1913    }
1914
1915    fn segment_path(&self, start: u64, end: u64) -> PathBuf {
1916        self.dir.join(segment::filename(start, end))
1917    }
1918}
1919
1920/// RAII guard that clears [`SegmentBuffer::iteration_in_progress`] on drop,
1921/// including during panic unwinding. Without this, a panicking `for_each_from`
1922/// callback would leave the flag set and permanently brick the buffer.
1923struct IterationGuard<'a>(&'a std::sync::atomic::AtomicBool);
1924
1925impl Drop for IterationGuard<'_> {
1926    fn drop(&mut self) {
1927        self.0.store(false, std::sync::atomic::Ordering::Relaxed);
1928    }
1929}
1930
1931/// Owned-item iterator over buffer contents, yielding `(seq, item)` pairs.
1932///
1933/// Returned by [`SegmentBuffer::iter_from`]. Materialises up to `limit`
1934/// items eagerly (memory cost `O(limit)`); for a lending iterator that
1935/// passes in-memory items by reference without cloning, use
1936/// [`SegmentBuffer::for_each_from`].
1937///
1938/// The iterator borrows the buffer for `'a`. Drop it before calling any
1939/// other `&self` method on the same buffer — the re-entrancy contract is
1940/// the same as [`SegmentBuffer::for_each_from`].
1941///
1942/// # Example
1943///
1944/// ```
1945/// use segment_buffer::{SegmentBuffer, SegmentConfig};
1946/// use tempfile::tempdir;
1947///
1948/// let dir = tempdir()?;
1949/// let buf: SegmentBuffer<u64> =
1950///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1951/// buf.append(7)?;
1952/// buf.append(8)?;
1953/// buf.flush()?;
1954///
1955/// let collected: Vec<u64> = buf.iter_from(0, 100)?
1956///     .map(|(_seq, item)| item)
1957///     .collect();
1958/// assert_eq!(collected, vec![7, 8]);
1959/// # Ok::<(), Box<dyn std::error::Error>>(())
1960/// ```
1961pub struct SegmentIter<'a, T> {
1962    inner: std::vec::IntoIter<(u64, T)>,
1963    // Tie the iterator's lifetime to the buffer borrow so callers can't
1964    // outlive the buffer or sneak in a `flush`/`append`/`delete_acked`
1965    // call while the iterator is live (those methods would panic via
1966    // assert_not_reentered anyway, but the borrow makes it a compile-time
1967    // guarantee for &self methods that don't take the inner lock).
1968    _phantom: std::marker::PhantomData<&'a SegmentBuffer<T>>,
1969}
1970
1971impl<T> Iterator for SegmentIter<'_, T> {
1972    type Item = (u64, T);
1973
1974    fn next(&mut self) -> Option<Self::Item> {
1975        self.inner.next()
1976    }
1977
1978    fn size_hint(&self) -> (usize, Option<usize>) {
1979        self.inner.size_hint()
1980    }
1981}
1982
1983impl<T> std::iter::FusedIterator for SegmentIter<'_, T> {}
1984
1985impl<T> Drop for SegmentBuffer<T> {
1986    /// Releases the single-process flock by explicitly calling `unlock` and
1987    /// then dropping the lock file handle. The kernel would release the
1988    /// advisory lock on fd close anyway, but the explicit call makes the
1989    /// release point diagnosable in a flamegraph (vs. waiting for `File`'s
1990    /// own `Drop` to run somewhere in the field-tear-down sequence).
1991    ///
1992    /// Deliberately no `T: Serialize + ...` bound: `Drop` impls must match
1993    /// the struct's bounds (Rust rule E0367), and the struct itself has no
1994    /// bounds — the bound lives on the API-impl block. The lock-release
1995    /// logic doesn't touch `T` at all, so no bound is needed here.
1996    fn drop(&mut self) {
1997        if let Some(lock_file) = self.lock_file.take() {
1998            // Best-effort unlock: if it fails (kernel EINTR, already closed,
1999            // etc.) there is nothing useful to do — the fd is about to be
2000            // dropped, which releases the lock unconditionally. Suppress the
2001            // unused-result warning; we already have the strong guarantee.
2002            let _ = fs4::FileExt::unlock(&lock_file);
2003            drop(lock_file);
2004        }
2005    }
2006}
2007
2008/// Probe whether the filesystem at `dir` updates a file's mtime on a
2009/// sub-second write-after-write window.
2010///
2011/// Writes a sentinel file twice with a ~15ms sleep between, then compares
2012/// the kernel-reported mtime. Modern local filesystems (ext4/xfs/btrfs/
2013/// apfs/ntfs) all qualify; some FUSE mounts, network filesystems with
2014/// coarse granularity, and memoised-overlay filesystems pin mtime to a
2015/// constant and would fail the probe.
2016///
2017/// Returns `false` on ANY failure (write error, stat error, mtime
2018/// unchanged) — the caller treats a `false` as "do not consult mtime when
2019/// validating the scan cache" (the cache stays warm until an in-process
2020/// mutation invalidates it). This is the safe default: comparing two
2021/// `0 == 0` mtimes would falsely confirm cache validity on a no-mtime
2022/// filesystem, silently serving stale data forever.
2023fn probe_mtime_capability(dir: &std::path::Path) -> bool {
2024    let sentinel = dir.join(".segment-buffer.mtime-probe");
2025    let _ = std::fs::write(&sentinel, b"a");
2026    let t1 = std::fs::metadata(&sentinel).and_then(|m| m.modified()).ok();
2027    std::thread::sleep(std::time::Duration::from_millis(15));
2028    let _ = std::fs::write(&sentinel, b"b");
2029    let t2 = std::fs::metadata(&sentinel).and_then(|m| m.modified()).ok();
2030    let _ = std::fs::remove_file(&sentinel);
2031    matches!((t1, t2), (Some(a), Some(b)) if a != b)
2032}
2033
2034// ---------------------------------------------------------------------------
2035// Static thread-safety assertion
2036// ---------------------------------------------------------------------------
2037
2038// `SegmentBuffer<T>` is documented as MPMC-safe via `parking_lot::Mutex`. This
2039// fails to compile if anyone ever introduces a non-`Send`/`Sync` field on
2040// `SegmentBuffer` or `BufferInner` (e.g. an `Rc`), turning the documented
2041// thread-safety guarantee into a compile-time contract instead of a comment.
2042const _: () = {
2043    const fn assert_send_sync<T: Send + Sync>() {}
2044    assert_send_sync::<SegmentBuffer<()>>();
2045};
2046
2047#[cfg(test)]
2048mod tests;
2049
2050#[cfg(test)]
2051mod property_tests;
2052
2053// Each example file is embedded as a doc-test so `cargo test --doc` gives
2054// execution coverage on top of the compilation coverage from
2055// `cargo test --examples`. The `concat!` wraps the raw file content in a
2056// code fence so rustdoc treats it as compilable+runnable Rust.
2057#[cfg(doctest)]
2058mod example_doctests {
2059    #[doc = concat!("```rust\n", include_str!("../examples/basic_usage.rs"), "\n```")]
2060    const BASIC_USAGE: () = ();
2061
2062    #[doc = concat!("```rust\n", include_str!("../examples/backpressure.rs"), "\n```")]
2063    const BACKPRESSURE: () = ();
2064
2065    #[doc = concat!("```rust\n", include_str!("../examples/crash_recovery.rs"), "\n```")]
2066    const CRASH_RECOVERY: () = ();
2067
2068    #[doc = concat!("```rust\n", include_str!("../examples/mpmc.rs"), "\n```")]
2069    const MPMC: () = ();
2070
2071    #[cfg(feature = "encryption")]
2072    #[doc = concat!("```rust\n", include_str!("../examples/encrypted.rs"), "\n```")]
2073    const ENCRYPTED: () = ();
2074}