Skip to main content

segment_buffer/
lib.rs

1//! High-throughput **local buffer for cloud sync** — single-process by design,
2//! durability-configurable, optional performant encryption, at-least-once delivery.
3//!
4//! Items are accumulated in memory, flushed as zstd-compressed CBOR batches
5//! to `seg_{start:012}_{end:012}.zst` files, and deleted once the consumer
6//! acknowledges receipt via [`SegmentBuffer::delete_acked`].
7//!
8//! The buffer is generic over any `T: Serialize + DeserializeOwned + Clone + Send`.
9//! (No explicit `'static` bound is required: `DeserializeOwned` already implies
10//! it, since a borrowed type cannot satisfy `for<'de> Deserialize<'de>`.)
11//! Crash recovery is filename-based: scanning the directory rebuilds `head_seq`
12//! and `next_seq` without any WAL or metadata database.
13//!
14//! # Delivery guarantees
15//!
16//! The crate provides **at-least-once delivery**. `append()` returns a stable
17//! sequence number; `delete_acked(seq)` is the commit point. Crash before the
18//! ack and items are re-delivered on recovery. Making this effectively-once
19//! requires server-side idempotency on `(producer_id, seq)` — see
20//! `examples/idempotent_server.rs`.
21//!
22//! Under the canonical single-consumer drain loop (`read_from → upload →
23//! delete_acked`, sequential), the buffer also provides read-your-writes,
24//! monotonic reads, and contiguous results. Under concurrent multi-reader
25//! operation, two narrow race windows open (spurious Io errors from
26//! concurrent `delete_acked`; transient gaps from concurrent `flush`) that
27//! do not corrupt data but change the result shape. See the
28//! [Consistency model](https://github.com/LarsArtmann/segment-buffer/blob/master/docs/DOMAIN_LANGUAGE.md#consistency-model) section of
29//! the Domain Language doc for the full guarantee table and practical
30//! guidance.
31//!
32//! # Schema evolution of `T`
33//!
34//! The crate has two versioning layers: the `SBF1` envelope (crate-managed,
35//! forward-evolvable) and the CBOR payload of `T` (caller-managed,
36//! unversioned). Changing `T` in a backward-incompatible way will break
37//! deserialization of old segment files. See the
38//! [Schema evolution](https://github.com/LarsArtmann/segment-buffer/blob/master/docs/DOMAIN_LANGUAGE.md#schema-evolution-of-t)
39//! section for compatible-change patterns and migration strategies.
40//!
41//! # Example
42//!
43//! ```no_run
44//! use segment_buffer::{SegmentBuffer, SegmentConfig};
45//! use serde::{Serialize, Deserialize};
46//!
47//! #[derive(Serialize, Deserialize, Clone)]
48//! struct MyItem { id: u64 }
49//!
50//! let buffer = SegmentBuffer::<MyItem>::open("/tmp/my-queue", SegmentConfig::default())?;
51//! let seq = buffer.append(MyItem { id: 1 })?;
52//! let items = buffer.read_from(0, 100)?;
53//! # Ok::<(), Box<dyn std::error::Error>>(())
54//! ```
55//!
56//! For the full README — install, quickstart, encryption, backpressure,
57//! comparison table, and performance notes — see the
58//! [project README on GitHub](https://github.com/LarsArtmann/segment-buffer#segment-buffer)
59//! or [docs.rs](https://docs.rs/segment-buffer).
60//!
61//! # Examples
62//!
63//! The `examples/` directory in the source tree holds runnable end-to-end
64//! demos keyed by use case. Build and run any of them with
65//! `cargo run --example <name>` (encryption examples need
66//! `--features encryption`):
67//!
68//! | Example                | What it shows                                                                                  |
69//! | ---------------------- | ---------------------------------------------------------------------------------------------- |
70//! | `basic_usage`          | Minimum append/read/delete cycle.                                                              |
71//! | `cloud_sync`           | Full at-least-once drain loop with retry under transient failures.                             |
72//! | `cloud_sync_disk_full` | Drain loop that pushes backpressure up to the producer when `store_pressure()` exceeds a threshold. |
73//! | `idempotent_server`    | Server-side `(producer_id, seq)` dedup pattern that makes at-least-once effectively-once.     |
74//! | `crash_recovery`       | Flushed segments survive a simulated crash; unflushed don't; `open_with_report` prints the recovery scan. |
75//! | `backpressure`         | The canonical pattern for translating `store_pressure()` into an admission decision.           |
76//! | `background_flush`     | `FlushPolicy::Manual` + a caller-owned timer thread for p99-sensitive producers.               |
77//! | `mpmc`                 | Multi-producer / multi-consumer sharing via `Arc<SegmentBuffer<T>>`.                           |
78//! | `hotpath_profile`      | Latency-histogram harness for the append hot path.                                             |
79//! | `scaling`              | End-to-end 1M–100M lifecycle throughput.                                                       |
80//! | `encrypted`            | AES-256-GCM and XChaCha20-Poly1305 ciphers end-to-end (requires `--features encryption`).      |
81//! | `bring_your_own_cipher`| Implementing the `SegmentCipher` trait for a custom cipher (requires `--features encryption`). |
82
83#![warn(missing_docs)]
84// Require every public function that can panic or return Result to document
85// the failure mode. Prevents the # Panics / # Errors sections from silently
86// rotting when new methods land. The 2026-07-20 doc-quality sweep added the
87// sections; these lints keep them there.
88#![warn(clippy::missing_panics_doc, clippy::missing_errors_doc)]
89// Library-only panic-prevention lints (inspired by namtao's "Strict Lints"
90// philosophy). These are crate-level denies so they apply to every source
91// Panic-prevention lints for library code. These are also denied in
92// Cargo.toml [lints.clippy] for all targets; the in-crate test modules
93// (src/tests.rs, src/property_tests.rs) override with `#![allow]`.
94// Benches and examples carry their own `#![allow]` blocks.
95//
96// The full strict set (`as_conversions`, `arithmetic_side_effects`,
97// `pedantic`, `nursery`) is also enforced via Cargo.toml. Library code is
98// fully clean under all of them.
99#![deny(
100    clippy::unwrap_used,
101    clippy::expect_used,
102    clippy::indexing_slicing,
103    clippy::string_slice,
104    clippy::panic_in_result_fn
105)]
106// Pin the html root URL so intra-doc links resolve against the published
107// docs.rs page for this exact version, not whatever rustdoc guessed. Keeps
108// `[\`SegmentBuffer\`]`-style links stable across local and docs.rs builds.
109// Bump the version segment when cutting a release.
110#![doc(html_root_url = "https://docs.rs/segment-buffer/0.5.5")]
111// On docs.rs (nightly), enable the `doc_cfg` feature so feature-gated items
112// show an "Available on feature `encryption` only" badge. Inert on local
113// builds (stable) where `docsrs` is never set.
114#![cfg_attr(docsrs, feature(doc_cfg))]
115// The crate-root rustdoc is the hand-written block above. The full README
116// (install, quickstart, encryption, comparison table, performance) is NOT
117// embedded here: it is rendered separately by docs.rs via the `readme` field
118// in Cargo.toml, and embedding it via `include_str!` caused two real problems
119// — (1) `craneLib.cleanCargoSource` strips README.md from the Nix sandbox,
120// needing a `postUnpack` band-aid, and (2) the README's cloud-sync doctest
121// referenced an undefined `cloud_upload` fn, turning `cargo test --doc` red.
122// Readers reach the README through the links above plus the docs.rs landing
123// page; the crate-root stays a concise, self-contained API orientation.
124
125mod cipher;
126mod error;
127mod segment;
128mod store;
129
130#[cfg(feature = "encryption")]
131#[cfg_attr(docsrs, doc(cfg(feature = "encryption")))]
132pub use cipher::{AesGcmCipher, XChaCha20Poly1305Cipher};
133pub use cipher::{CipherError, SegmentCipher};
134pub use error::{IoSite, Result, SegmentError};
135
136/// Test/loom-only re-exports: the I/O trait, production impl, and the
137/// range type used in trait signatures.
138///
139/// Reachable only when the `loom` Cargo feature is enabled (used by the
140/// `tests/loom.rs` integration test to inject a mock store). Not part of
141/// the stable semver surface: items reachable through this re-export may
142/// change in any release without a major bump. Mirrors the gating strategy
143/// used by `fuzz_hooks`.
144#[cfg(feature = "loom")]
145pub use segment::SegmentRange;
146#[cfg(feature = "loom")]
147pub use store::{RealStore, SegmentStore};
148
149/// Internal helpers exposed for in-tree fuzz targets and deep integration tests.
150///
151/// **Not part of the public API.** Reachable only when the `fuzz` Cargo feature
152/// is enabled (or under `cfg(test)`). Stability is not guaranteed — these may
153/// change or disappear in any release without bumping the major version.
154///
155/// Rationale: `#[doc(hidden)]` hides items from rustdoc but does **not** remove
156/// them from the semver surface. A `#[cfg]`-gated module does both: it disappears
157/// from docs *and* from the compiled crate when the feature is off, so downstream
158/// users who never opted into `fuzz` cannot reach these items at all. See
159/// `CONTRIBUTING.md` → "Internal hooks: `#[cfg]` over `#[doc(hidden)]`".
160#[cfg(any(test, feature = "fuzz"))]
161pub mod fuzz_hooks {
162    pub use crate::segment::{
163        filename, parse_filename, unwrap_envelope, wrap_envelope, SegmentRange,
164    };
165    pub use crate::FlushPolicy;
166
167    /// Fuzz-accessible wrapper for the private `should_flush` method.
168    /// Returns whether the given policy would trigger a flush given the
169    /// pending item count and elapsed time since the last flush.
170    #[must_use]
171    pub fn should_flush(
172        policy: &FlushPolicy,
173        pending_len: usize,
174        elapsed: std::time::Duration,
175    ) -> bool {
176        policy.should_flush(pending_len, elapsed)
177    }
178}
179
180use std::path::PathBuf;
181use std::sync::Arc;
182use std::time::Instant;
183
184use parking_lot::Mutex;
185use serde::de::DeserializeOwned;
186use serde::Serialize;
187use tracing::{debug, info};
188
189/// Filename of the single-process lock sidecar held open by every production
190/// [`SegmentBuffer`]. Lives inside the segment directory and is acquired
191/// exclusively at [`SegmentBuffer::open`]; the kernel releases the lock when
192/// the buffer is dropped (closing the fd). Loom-test opens
193/// ([`SegmentBuffer::open_with_store`]) skip the lock — loom does not model
194/// the filesystem, and a real lock file inside `loom::model` would deadlock.
195const LOCK_FILE_NAME: &str = ".segment-buffer.lock";
196
197/// When to auto-flush pending items from memory to a segment file.
198///
199/// Passed to [`SegmentConfig`] via its `flush_policy` field. Replaces the
200/// pre-v0.4.0 silent combination of two separate fields (`max_batch_events`
201/// and `flush_interval_secs`) that OR'd together without telling the caller
202/// which trigger fired.
203#[derive(Debug, Clone, PartialEq, Eq)]
204#[non_exhaustive]
205pub enum FlushPolicy {
206    /// Flush as soon as `batch_size` items are buffered. No interval trigger.
207    Batch(usize),
208    /// Flush as soon as `interval` has elapsed since the last flush. No batch
209    /// trigger.
210    ///
211    /// **Timing note:** the interval clock starts at `open()`, not at the
212    /// first `append()`. If the buffer sits idle after construction, the
213    /// first append will immediately trigger a flush.
214    Interval(std::time::Duration),
215    /// Flush when EITHER `batch_size` items are buffered OR `interval` has
216    /// elapsed since the last flush — whichever fires first. This is the
217    /// pre-v0.4.0 default behavior.
218    ///
219    /// **Caution:** during low-throughput periods this policy creates tiny
220    /// segment files (as small as 1 event) every `interval`. Use
221    /// [`BatchOrIntervalMin`](Self::BatchOrIntervalMin) to suppress interval
222    /// flushes below a minimum batch threshold.
223    ///
224    /// **Timing note:** the interval clock starts at `open()`, not at the
225    /// first `append()`.
226    BatchOrInterval {
227        /// In-memory item count threshold.
228        batch_size: usize,
229        /// Max time between flushes.
230        interval: std::time::Duration,
231    },
232    /// Flush when `batch_size` items are buffered, OR when `interval` has
233    /// elapsed AND at least `min_batch` items are pending, OR when
234    /// `max_interval` has elapsed regardless of pending count.
235    ///
236    /// This policy prevents tiny segment files during low-throughput periods:
237    /// the interval timer only triggers a flush if enough events have
238    /// accumulated to be worth writing. The `max_interval` safety valve
239    /// ensures events don't sit in memory indefinitely during idle periods
240    /// (protecting crash-recovery latency).
241    ///
242    /// Example: `batch_size=256, min_batch=10, interval=5s, max_interval=60s`
243    /// means: flush immediately at 256 events; every 5s, flush only if 10+
244    /// events are pending; every 60s, flush everything regardless.
245    BatchOrIntervalMin {
246        /// In-memory item count threshold for immediate flush.
247        batch_size: usize,
248        /// Minimum pending items before an interval-triggered flush fires.
249        /// Prevents writing tiny segments during low-throughput periods.
250        min_batch: usize,
251        /// Interval after which to flush if at least `min_batch` items
252        /// accumulated.
253        interval: std::time::Duration,
254        /// Absolute maximum time between flushes, regardless of pending count.
255        /// Ensures events don't sit in memory indefinitely during idle
256        /// periods.
257        max_interval: std::time::Duration,
258    },
259    /// Never auto-flush. The caller must call [`SegmentBuffer::flush`]
260    /// explicitly to make appends durable. Useful for tests and for callers
261    /// that want absolute control over write amplification.
262    Manual,
263}
264
265impl Default for FlushPolicy {
266    fn default() -> Self {
267        // Matches the pre-v0.4.0 SegmentConfig::default: 256 events or 5s.
268        Self::BatchOrInterval {
269            batch_size: 256,
270            interval: std::time::Duration::from_secs(5),
271        }
272    }
273}
274
275impl std::fmt::Display for FlushPolicy {
276    /// Human-readable representation suitable for logging and diagnostics.
277    ///
278    /// The format is intentionally compact and stable across releases so
279    /// operators can parse it in log-scraping tools without breakage.
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            Self::Batch(n) => write!(f, "batch({n})"),
283            Self::Interval(d) => write!(f, "interval({d:?})"),
284            Self::BatchOrInterval {
285                batch_size,
286                interval,
287            } => {
288                write!(
289                    f,
290                    "batch_or_interval(batch={batch_size}, interval={interval:?})"
291                )
292            }
293            Self::BatchOrIntervalMin {
294                batch_size,
295                min_batch,
296                interval,
297                max_interval,
298            } => {
299                write!(
300                    f,
301                    "batch_or_interval_min(batch={batch_size}, min={min_batch}, interval={interval:?}, max={max_interval:?})"
302                )
303            }
304            Self::Manual => write!(f, "manual"),
305        }
306    }
307}
308
309impl FlushPolicy {
310    /// Returns `true` when the policy says the buffer should flush now.
311    ///
312    /// `pending_len` is the current length of the in-memory `unflushed` Vec;
313    /// `time_since_last_flush` is `last_flush.elapsed()`.
314    fn should_flush(&self, pending_len: usize, time_since_last_flush: std::time::Duration) -> bool {
315        match self {
316            Self::Batch(n) => pending_len >= *n,
317            Self::Interval(d) => time_since_last_flush >= *d,
318            Self::BatchOrInterval {
319                batch_size,
320                interval,
321            } => pending_len >= *batch_size || time_since_last_flush >= *interval,
322            Self::BatchOrIntervalMin {
323                batch_size,
324                min_batch,
325                interval,
326                max_interval,
327            } => {
328                pending_len >= *batch_size
329                    || time_since_last_flush >= *max_interval
330                    || (pending_len >= *min_batch && time_since_last_flush >= *interval)
331            }
332            Self::Manual => false,
333        }
334    }
335}
336
337/// Per-flush durability tradeoff between throughput and crash safety.
338///
339/// Selects how many `fsync`s the write path performs when [`flush`](SegmentBuffer::flush)
340/// spills a batch to disk. Higher durability costs throughput; lower
341/// durability relies on the cloud (or wherever the durable copy lives) to
342/// absorb crash loss. The cloud-sync vision for this crate makes
343/// [`Throughput`](Self::Throughput) the natural default once callers opt in,
344/// but [`Segment`](Self::Segment) remains the default for one release after
345/// the enum lands to avoid silently changing crash semantics for existing
346/// users.
347///
348/// # Crash-loss semantics
349///
350/// | Policy                 | Fsync file data | Fsync dir after rename | Worst-case crash loss                                |
351/// | ---------------------- | --------------- | --------------------- | ---------------------------------------------------- |
352/// | [`Maximal`](Self::Maximal)    | yes             | yes                   | last in-flight flush only                            |
353/// | [`Segment`](Self::Segment)    | yes             | no                    | rename window (~5–30s of flushes on ext4/xfs)        |
354/// | [`Throughput`](Self::Throughput) | no              | no                    | entire OS dirty window (~30s) — cloud is durable     |
355///
356/// `Maximal` is for standalone-queue deployments where this buffer is the
357/// last copy. `Throughput` is the correct choice for cloud-sync deployments
358/// where the cloud endpoint holds the durable copy and the local disk is a
359/// throughput buffer. `Segment` is the pre-v0.5.0 behavior, kept as the
360/// default for one release for backward compatibility.
361///
362/// # The rename-window gap (why `Segment` is not "fully durable")
363///
364/// `Segment` (today's default) calls `file.sync_all()` on the segment data
365/// before `fs::rename`, but it does **not** `dir.sync_all()` after the
366/// rename. On ext4/xfs defaults, a host crash within the kernel's dir-inode
367/// flush window (~5–30s) can leave the renamed file's data on disk but
368/// unreachable through the directory. `SQLite` went through this exact lesson.
369/// So `Segment` was already not fully durable; the enum just makes the
370/// tradeoff explicit. `Maximal` closes the rename-window gap.
371///
372/// # Implementation
373///
374/// The policy is branched on inside `SegmentStore::write_atomic`
375/// (not a callback): it is a `Copy` enum with no allocation, and the
376/// `Mutex<Compressor>` invariant ("never held across I/O") is preserved
377/// because the fsync happens after compression is done and the mutex is
378/// released.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
380#[non_exhaustive]
381pub enum DurabilityPolicy {
382    /// Fsync the segment file's data **and** the parent directory inode
383    /// after rename. Closes the rename-window gap. Use when this buffer is
384    /// the last copy of the data (standalone-queue deployments).
385    Maximal,
386
387    /// Fsync the segment file's data, but not the directory inode after
388    /// rename. This is the pre-v0.5.0 behavior. Kept as the
389    /// [`Default`] for one release after the enum
390    /// lands, then flips to [`Throughput`](Self::Throughput) with a
391    /// deprecation note.
392    #[default]
393    Segment,
394
395    /// Skip fsync entirely. The kernel's dirty-page flusher handles when the
396    /// bytes reach disk (~30s on default Linux). The rename is still atomic,
397    /// so concurrent readers never see a partial write — only a host crash
398    /// within the dirty window can lose the segment. Use when the cloud is
399    /// the durable layer and this buffer is the throughput buffer in front
400    /// of it.
401    Throughput,
402}
403
404/// Configuration knobs for [`SegmentBuffer`].
405///
406/// This struct is `#[non_exhaustive]`: new fields may be added in any release
407/// without breaking semver. Construct via [`SegmentConfig::builder()`] and then
408/// mutate the public fields you care about, or use [`SegmentConfig::default()`]
409/// directly:
410///
411/// ```
412/// use segment_buffer::SegmentConfig;
413///
414/// let mut config = SegmentConfig::default();
415/// config.max_size_bytes = 1024 * 1024;
416/// ```
417#[non_exhaustive]
418#[derive(Clone)]
419pub struct SegmentConfig {
420    /// When to auto-flush pending items. See [`FlushPolicy`] for the options.
421    pub flush_policy: FlushPolicy,
422    /// Max total disk usage before the buffer reports overload pressure (default: 10 GB).
423    pub max_size_bytes: u64,
424    /// zstd compression level (1-22; default **3**, fast with a good ratio).
425    pub compression_level: i32,
426    /// Per-flush fsync behavior. See [`DurabilityPolicy`] for the three
427    /// policies and their crash-loss tradeoffs. Default is
428    /// [`DurabilityPolicy::Segment`] (today's behavior) for backward
429    /// compatibility; cloud-sync deployments should switch to
430    /// [`DurabilityPolicy::Throughput`] once the cloud endpoint holds the
431    /// durable copy.
432    pub durability: DurabilityPolicy,
433    /// Optional cipher for encrypting segment files at rest. When `None`,
434    /// segments are written as plaintext zstd+CBOR. Held as an [`Arc`] so a
435    /// [`SegmentConfig`] is [`Clone`] and the same cipher can be shared
436    /// across multiple buffers or cloned into a `recommended_cipher()` helper.
437    pub cipher: Option<Arc<dyn SegmentCipher + Send + Sync>>,
438}
439
440impl std::fmt::Debug for SegmentConfig {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        f.debug_struct("SegmentConfig")
443            .field("flush_policy", &self.flush_policy)
444            .field("max_size_bytes", &self.max_size_bytes)
445            .field("compression_level", &self.compression_level)
446            .field("durability", &self.durability)
447            .field("cipher", &self.cipher.as_ref().map(|_| "[set]"))
448            .finish()
449    }
450}
451
452impl Default for SegmentConfig {
453    fn default() -> Self {
454        Self {
455            flush_policy: FlushPolicy::default(),
456            max_size_bytes: 10 * 1024 * 1024 * 1024,
457            compression_level: 3,
458            durability: DurabilityPolicy::default(),
459            cipher: None,
460        }
461    }
462}
463
464/// Ergonomic builder for [`SegmentConfig`].
465///
466/// `SegmentConfig` is `#[non_exhaustive]`, so direct struct-literal
467/// construction is forbidden outside the crate. The builder is the
468/// recommended way for callers to override one or two fields without
469/// re-typing every default.
470///
471/// ```
472/// use segment_buffer::{FlushPolicy, SegmentConfig};
473/// use std::time::Duration;
474///
475/// let config = SegmentConfig::builder()
476///     .flush_policy(FlushPolicy::Batch(64))
477///     .compression_level(6)
478///     .build();
479/// assert_eq!(config.flush_policy, FlushPolicy::Batch(64));
480/// assert_eq!(config.compression_level, 6);
481/// // Untouched fields fall back to Default.
482/// assert_eq!(config.max_size_bytes, 10 * 1024 * 1024 * 1024);
483/// ```
484#[derive(Debug, Clone)]
485pub struct SegmentConfigBuilder {
486    inner: SegmentConfig,
487}
488
489impl SegmentConfigBuilder {
490    /// Override the auto-flush policy. See [`FlushPolicy`] for variants.
491    #[must_use]
492    pub const fn flush_policy(mut self, policy: FlushPolicy) -> Self {
493        self.inner.flush_policy = policy;
494        self
495    }
496
497    /// Convenience: install a `FlushPolicy::Batch(batch_size)`.
498    #[must_use]
499    pub const fn flush_at_batch_size(self, batch_size: usize) -> Self {
500        self.flush_policy(FlushPolicy::Batch(batch_size))
501    }
502
503    /// Convenience: install a `FlushPolicy::Interval(interval)`.
504    #[must_use]
505    pub const fn flush_at_interval(self, interval: std::time::Duration) -> Self {
506        self.flush_policy(FlushPolicy::Interval(interval))
507    }
508
509    /// Convenience: install a `FlushPolicy::BatchOrInterval { .. }` with both
510    /// triggers set.
511    #[must_use]
512    pub const fn flush_at_batch_or_interval(
513        self,
514        batch_size: usize,
515        interval: std::time::Duration,
516    ) -> Self {
517        self.flush_policy(FlushPolicy::BatchOrInterval {
518            batch_size,
519            interval,
520        })
521    }
522
523    /// Convenience: install a [`FlushPolicy::BatchOrIntervalMin`] with all four
524    /// parameters. Suppresses tiny segments during low-throughput periods by
525    /// gating interval flushes on a minimum batch count.
526    #[must_use]
527    pub fn flush_at_batch_or_interval_min(
528        self,
529        batch_size: usize,
530        min_batch: usize,
531        interval: std::time::Duration,
532        max_interval: std::time::Duration,
533    ) -> Self {
534        debug_assert!(
535            min_batch <= batch_size,
536            "min_batch ({min_batch}) must not exceed batch_size ({batch_size}) — \
537             otherwise the interval trigger is unreachable"
538        );
539        debug_assert!(
540            interval <= max_interval,
541            "interval ({interval:?}) must not exceed max_interval ({max_interval:?}) — \
542             otherwise the gated interval is unreachable"
543        );
544        self.flush_policy(FlushPolicy::BatchOrIntervalMin {
545            batch_size,
546            min_batch,
547            interval,
548            max_interval,
549        })
550    }
551
552    /// Convenience: install a `FlushPolicy::Manual` (no auto-flush).
553    #[must_use]
554    pub const fn flush_manually(self) -> Self {
555        self.flush_policy(FlushPolicy::Manual)
556    }
557
558    /// Override the disk-usage ceiling that triggers `is_overloaded()`.
559    #[must_use]
560    pub const fn max_size_bytes(mut self, max_size_bytes: u64) -> Self {
561        self.inner.max_size_bytes = max_size_bytes;
562        self
563    }
564
565    /// Override the zstd compression level (1-22; default 3, fast with a good ratio).
566    #[must_use]
567    pub const fn compression_level(mut self, compression_level: i32) -> Self {
568        self.inner.compression_level = compression_level;
569        self
570    }
571
572    /// Override the per-flush durability policy. See [`DurabilityPolicy`] for
573    /// the three policies and their crash-loss tradeoffs.
574    ///
575    /// The default is [`DurabilityPolicy::Segment`] for backward
576    /// compatibility. For cloud-sync deployments where the cloud endpoint
577    /// holds the durable copy, [`DurabilityPolicy::Throughput`] eliminates
578    /// the per-flush fsync from the hot path (typically a 5–10× win on fast
579    /// storage).
580    #[must_use]
581    pub const fn durability(mut self, policy: DurabilityPolicy) -> Self {
582        self.inner.durability = policy;
583        self
584    }
585
586    /// Install a [`SegmentCipher`] so segment payloads are encrypted at rest.
587    ///
588    /// Accepts an [`Arc`] so the same cipher can be shared across multiple
589    /// buffers or cloned into a `recommended_cipher()` helper. The canonical
590    /// construction pattern is:
591    ///
592    /// ```no_run
593    /// # #[cfg(feature = "encryption")] {
594    /// use segment_buffer::{AesGcmCipher, SegmentConfig};
595    /// use std::sync::Arc;
596    /// let cfg = SegmentConfig::builder()
597    ///     .cipher(Arc::new(AesGcmCipher::new(&[0u8; 32])))
598    ///     .build();
599    /// # }
600    /// ```
601    #[must_use]
602    pub fn cipher(mut self, cipher: Arc<dyn SegmentCipher + Send + Sync>) -> Self {
603        self.inner.cipher = Some(cipher);
604        self
605    }
606
607    /// Install the cipher this crate recommends for **new buffers**.
608    ///
609    /// Available only under the `encryption` feature. Picks
610    /// [`XChaCha20Poly1305Cipher`] (24-byte extended nonce, no 2³²-message
611    /// limit per key, constant-time on hosts without AES-NI). Legacy
612    /// AES-GCM segments still decrypt through [`AesGcmCipher`]; the two
613    /// formats are byte-distinguishable only by which cipher the buffer
614    /// was opened with.
615    ///
616    /// # Example
617    ///
618    /// ```no_run
619    /// # #[cfg(feature = "encryption")] {
620    /// use segment_buffer::SegmentConfig;
621    /// let cfg = SegmentConfig::builder()
622    ///     .recommended_cipher([0u8; 32])
623    ///     .build();
624    /// # }
625    /// ```
626    #[cfg(feature = "encryption")]
627    #[cfg_attr(docsrs, doc(cfg(feature = "encryption")))]
628    #[must_use]
629    pub fn recommended_cipher(self, key: [u8; 32]) -> Self {
630        self.cipher(Arc::new(XChaCha20Poly1305Cipher::new(&key)))
631    }
632
633    /// Materialise the configured [`SegmentConfig`].
634    #[must_use]
635    pub fn build(self) -> SegmentConfig {
636        self.inner
637    }
638}
639
640impl SegmentConfig {
641    /// Begin a builder. Every field starts at [`SegmentConfig::default`];
642    /// chain setter calls to override the ones you care about.
643    #[must_use = "the builder is meaningless if discarded"]
644    pub fn builder() -> SegmentConfigBuilder {
645        SegmentConfigBuilder {
646            inner: Self::default(),
647        }
648    }
649}
650
651/// Point-in-time snapshot of buffer state, captured atomically under a single
652/// lock acquisition so all fields are mutually consistent.
653///
654/// Returned by [`SegmentBuffer::stats`]. Useful for metrics endpoints or
655/// dashboards that need to observe multiple values without paying for several
656/// lock/unlock round-trips (and risking a torn read between calls).
657///
658/// This struct is `#[non_exhaustive]`: new fields may be added in any release
659/// without breaking semver. It is constructed internally by [`SegmentBuffer::stats`];
660/// callers read fields via dot-syntax or pattern-match with `..` only.
661#[derive(Debug, Clone)]
662#[non_exhaustive]
663pub struct BufferStats {
664    /// Items waiting in the buffer (on-disk + in-memory pending).
665    /// Same value as [`SegmentBuffer::pending_count`].
666    pub pending_count: u64,
667    /// Highest sequence number assigned (or `0` if the buffer is empty).
668    /// Same value as [`SegmentBuffer::latest_sequence`].
669    pub latest_sequence: u64,
670    /// Oldest unacknowledged sequence number (`head_seq`).
671    pub head_sequence: u64,
672    /// Next sequence number that will be assigned by the next successful
673    /// [`SegmentBuffer::append`] (`next_seq`).
674    pub next_sequence: u64,
675    /// Approximate total bytes used by segment files on disk. Decreases when
676    /// [`SegmentBuffer::delete_acked`] removes files.
677    pub approx_disk_bytes: u64,
678    /// Number of segment files currently on disk. Incremented by
679    /// [`SegmentBuffer::flush`], decremented by
680    /// [`SegmentBuffer::delete_acked`], and recalibrated by
681    /// [`SegmentBuffer::sync_disk_bytes`]. Unlike
682    /// [`RecoveryReport::segment_count`] (a one-time open-time snapshot),
683    /// this value is live — call [`SegmentBuffer::stats`] to observe it.
684    pub segment_count: u64,
685    /// Configured ceiling on disk usage (`max_size_bytes`). `0` disables the
686    /// limit; in that case [`store_pressure`](Self::store_pressure) is `0.0`.
687    pub max_size_bytes: u64,
688    /// `approx_disk_bytes / max_size_bytes`, clamped to `[0.0, 1.0]`.
689    /// `0.0` when no limit is configured.
690    pub store_pressure: f32,
691}
692
693/// Size distribution of the on-disk segment files at a point in time.
694///
695/// Returned by [`SegmentBuffer::segment_size_stats`]. Unlike
696/// [`BufferStats`] (which derives [`BufferStats::segment_count`] and
697/// [`BufferStats::approx_disk_bytes`] from cheap atomic counters maintained
698/// on the flush/delete hot path), this struct is computed by a fresh
699/// directory scan: every field reflects the segment files as they actually
700/// are at call time. It is the tuning primitive for [`FlushPolicy::Batch`]:
701/// it answers "are my segments the size I expect, or is the batch size
702/// producing too many tiny files / too few huge ones?"
703///
704/// All byte values are the **on-disk (compressed, post-envelope) file
705/// lengths**, not item counts. Two segments holding the same number of
706/// items can differ in bytes because of compression and payload shape, so
707/// byte-size distribution is the honest signal for disk-footprint tuning.
708///
709/// # Percentile definition
710///
711/// [`p50_bytes`](Self::p50_bytes) and [`p90_bytes`](Self::p90_bytes) use
712/// the **nearest-rank** method: the value returned is always an actual
713/// segment file size, never an interpolation between two. For `n` segments
714/// sorted ascending, the `p`-th percentile is the element at 1-based rank
715/// `clamp(ceil(p / 100 · n), 1, n)`. Consequences:
716///
717/// - With one segment, `min`, `p50`, `p90`, and `max` are all equal.
718/// - `p50` is the lower median (the `ceil(n / 2)`-th smallest element).
719/// - `p90` is the size at or below which ~90% of segments fall.
720///
721/// When the buffer has no on-disk segments (nothing flushed yet, or
722/// everything acked), every field is `0`.
723///
724/// This struct is `#[non_exhaustive]`: new fields (e.g. `p99_bytes`) may be
725/// added in any release without breaking semver.
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727#[non_exhaustive]
728pub struct SegmentSizeStats {
729    /// Number of segment files on disk at scan time. Equals
730    /// [`BufferStats::segment_count`] immediately after a
731    /// [`sync_disk_bytes`](SegmentBuffer::sync_disk_bytes), but may differ
732    /// from the live atomic counter between recalibrations.
733    pub count: u64,
734    /// Smallest segment file size in bytes. `0` when there are no segments.
735    pub min_bytes: u64,
736    /// Largest segment file size in bytes. `0` when there are no segments.
737    pub max_bytes: u64,
738    /// Arithmetic mean segment size (`total_bytes / count`), truncated to
739    /// the integer. `0` when there are no segments.
740    pub mean_bytes: u64,
741    /// Median (50th percentile) segment size, nearest-rank. `0` when there
742    /// are no segments.
743    pub p50_bytes: u64,
744    /// 90th percentile segment size, nearest-rank. `0` when there are no
745    /// segments.
746    pub p90_bytes: u64,
747}
748
749/// Summary of the recovery scan performed by [`SegmentBuffer::open`].
750///
751/// Returned by [`SegmentBuffer::open_with_report`] for programmatic
752/// introspection. The same data is logged via `tracing` from
753/// [`SegmentBuffer::open`]; this struct is for callers that want to inspect
754/// it without parsing logs.
755///
756/// All fields are snapshots taken during recovery — they may be stale by the
757/// time the caller reads them, because other threads can append/flush/delete
758/// immediately after `open` returns. For a live view, use
759/// [`SegmentBuffer::stats`].
760///
761/// # Recovering over a populated directory
762///
763/// ```
764/// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
765/// use tempfile::tempdir;
766///
767/// let dir = tempdir()?;
768///
769/// // First instance: write three items, flush, drop.
770/// {
771///     let config = SegmentConfig::builder()
772///         .flush_policy(FlushPolicy::Manual)
773///         .build();
774///     let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
775///     for i in 0..3u64 { buf.append(i)?; }
776///     buf.flush()?;
777/// }
778///
779/// // Re-open: recovery must find one segment covering seqs 0..=2.
780/// let (buf, report) =
781///     SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
782/// assert_eq!(report.segment_count, 1);
783/// assert_eq!(report.head_seq, 0);
784/// assert_eq!(report.next_seq, 3);
785/// assert!(report.disk_bytes > 0, "flushed segment must have nonzero size");
786/// assert_eq!(report.removed_tmp_files, 0);
787/// # Ok::<(), Box<dyn std::error::Error>>(())
788/// ```
789#[derive(Debug, Clone, PartialEq, Eq)]
790#[non_exhaustive]
791pub struct RecoveryReport {
792    /// Number of valid segment files found on disk during recovery. `usize`
793    /// because it is derived from a one-time `Vec::len()` at recovery; the
794    /// live counterpart in [`BufferStats`] is `u64` because it is maintained
795    /// as an atomic counter on the flush/delete hot path.
796    pub segment_count: usize,
797    /// Oldest sequence number recovered (the `start` of the first segment),
798    /// or `0` when the directory was empty.
799    pub head_seq: u64,
800    /// Next sequence number that will be assigned by the next
801    /// [`SegmentBuffer::append`] (the `end + 1` of the last segment), or `0`
802    /// when the directory was empty.
803    pub next_seq: u64,
804    /// Total bytes of all recovered segment files (sum of file sizes).
805    pub disk_bytes: u64,
806    /// Number of `.tmp` debris files removed by recovery's cleanup step.
807    pub removed_tmp_files: usize,
808}
809
810struct BufferInner<T> {
811    /// Items buffered in memory, not yet written to a segment file. Drained by
812    /// [`SegmentBuffer::flush`] and rebuilt empty on crash recovery (unflushed
813    /// items do not survive a crash by design).
814    unflushed: Vec<T>,
815    next_seq: u64,
816    head_seq: u64,
817    last_flush: Instant,
818}
819
820/// High-throughput local buffer for cloud sync, holding items of `T` in
821/// memory and spilling them to compressed segment files for at-least-once
822/// delivery to a cloud endpoint.
823///
824/// Thread-safe via `parking_lot::Mutex`. All file I/O is synchronous. The mutex
825/// is never held across an async boundary because there are no await points.
826///
827/// Create with [`SegmentBuffer::open`], supplying the directory and config.
828///
829/// # Concurrency
830///
831/// `SegmentBuffer<T>` is `Send + Sync` (statically asserted in `lib.rs`) and
832/// safe to share across threads via `Arc<SegmentBuffer<T>>`:
833///
834/// - **MPMC, one lock.** Every mutating operation (`append`, `append_all`,
835///   `flush`, `delete_acked`) and every read (`read_from`, `iter_from`,
836///   `for_each_from`, `stats`) acquires a single `parking_lot::Mutex` for the
837///   duration of the in-memory state touch. Multiple producers and multiple
838///   consumers are supported inside one process.
839/// - **One owner process per directory.** The lock is *not* distributed.
840///   [`open`](Self::open) acquires an exclusive `flock` on
841///   `<dir>/.segment-buffer.lock` and fails fast with [`SegmentError::Locked`]
842///   if another process already holds it. Multiple threads inside the owner
843///   process are fine; multiple processes on the same directory are rejected.
844/// - **The mutex is never held across file I/O.** `flush()` drops the lock
845///   before the encode pipeline (CBOR → zstd → optional cipher → atomic
846///   rename) and re-acquires it only to bump `approx_disk_bytes`. `recover()`
847///   collects all segment metadata before taking the lock once to publish the
848///   rebuilt state. There are no await points; all I/O is synchronous.
849/// - **The `delete_acked` + `append` interleaving is loom-proven.** The
850///   `head_seq <= pending_start` clamp that keeps acks from advancing past
851///   unflushed items is exhaustively enumerated across every two-thread
852///   schedule by the loom tests in `tests/loom.rs` (4 tests, injected via a
853///   `MockStore` through `open_with_store`). The 8-writer/4-reader stress
854///   test in `src/tests.rs` covers the same contract statistically.
855/// - **Re-entrancy is safe, not a deadlock or panic.** The buffer mutex is
856///   never held across user callbacks (`for_each_from` snapshots and releases
857///   the lock before invoking `f`). Re-entrant calls (e.g. `append`, `stats`,
858///   `delete_acked` from a closure that captured an `Arc<SegmentBuffer<T>>`) are
859///   therefore safe and cannot deadlock — the public API is panic-free.
860#[doc(alias = "queue")]
861#[doc(alias = "spool")]
862#[doc(alias = "wal")]
863#[doc(alias = "writeahead")]
864#[doc(alias = "log")]
865pub struct SegmentBuffer<T> {
866    dir: PathBuf,
867    config: SegmentConfig,
868    inner: Mutex<BufferInner<T>>,
869    /// Total bytes used by segment files on disk. Updated atomically on
870    /// flush/delete/recover so `flush()` does not need to re-acquire the
871    /// mutex just to bump one u64. Read by `store_pressure` and `stats`.
872    /// Deliberately approximate: the real number can drift if files are
873    /// touched outside this crate, so it is suitable for backpressure
874    /// signalling and metrics, NOT for billing.
875    approx_disk_bytes: std::sync::atomic::AtomicU64,
876    /// Number of segment files on disk, tracked incrementally alongside
877    /// [`approx_disk_bytes`](Self::approx_disk_bytes). Incremented by one
878    /// on every [`flush`](Self::flush), decremented by the removal count on
879    /// every [`delete_acked`](Self::delete_acked), and recalibrated to the
880    /// directory scan result by [`recover`](Self::recover) and
881    /// [`sync_disk_bytes`](Self::sync_disk_bytes). Uses `Relaxed` ordering —
882    /// it is an approximate metric like `approx_disk_bytes`, so a torn read
883    /// relative to other operations is acceptable.
884    ///
885    /// # Underflow / wrap contract
886    ///
887    /// Because the increment (on `flush`) and decrement (on `delete_acked`)
888    /// are independent atomic ops, the value can momentarily wrap to a very
889    /// large `u64` in two situations, both benign and self-healing:
890    ///
891    /// 1. **External removal.** If segment files are deleted behind the
892    ///    buffer's back, a subsequent `delete_acked` still counts them as
893    ///    removed (its `deleted` total reflects the segments it observed at
894    ///    scan time), so `fetch_sub` may subtract more than the current
895    ///    atomic value, wrapping it past zero.
896    /// 2. **Concurrent flush + delete.** `delete_acked` can observe and
897    ///    remove a segment whose `flush` has written the file but not yet
898    ///    executed its `fetch_add(1)`; the `fetch_sub` then lands before the
899    ///    `fetch_add` in the atomic modification order, momentarily wrapping.
900    ///
901    /// In both cases the wrapped value is never observed as "correct" for
902    /// long: the next [`sync_disk_bytes`](Self::sync_disk_bytes),
903    /// [`recover`](Self::recover) (on reopen), or any `stats()` snapshot read
904    /// after a `sync_disk_bytes` overwrites it with the authoritative
905    /// directory-scan count. Callers that need an exact, non-wrapped value
906    /// should call `sync_disk_bytes()` first. The field is intentionally an
907    /// approximate metric for backpressure signalling, not a source of
908    /// truth — the directory is the source of truth.
909    segment_count: std::sync::atomic::AtomicU64,
910    /// Cache of `scan_segments()`. `None` means stale (must re-scan); `Some`
911    /// means a flush/`delete_acked` has not touched the directory since the
912    /// last scan. The cache is invalidated by every on-disk mutation
913    /// (`flush`, `delete_acked`, `recover`) and never goes stale any other
914    /// way — operators who manipulate the directory behind the buffer's back
915    /// get the directory scan cost back.
916    scan_cache: Mutex<Option<Vec<segment::SegmentRange>>>,
917    /// Pooled zstd compression context, allocated once at [`SegmentBuffer::open`]
918    /// and reused for every subsequent [`SegmentBuffer::flush`]. The flamegraph
919    /// captured on 2026-07-20 (see `docs/perf/2026-07-20_hot-path-flamegraph.md`)
920    /// showed 66% of `flush` CPU time was inside the `__memset` that
921    /// `zstd::encode_all` triggers when it constructs a fresh ~200 KB `CCtx`
922    /// per call. Pooling the `CCtx` through `zstd::bulk::Compressor` reduces
923    /// that init cost to a one-time `open` expense; subsequent flushes reuse
924    /// the same internal tables and pay only the per-frame `SessionOnly` reset
925    /// (~0.2% of CPU in the same profile).
926    ///
927    /// Behind its own `Mutex` (rather than living inside `BufferInner`) so
928    /// that holding it during the compression step does not extend the
929    /// hot-path `inner` mutex hold time. The mutex is uncontended in
930    /// practice: `flush` already takes `inner.lock()` briefly to drain the
931    /// pending events, and concurrent `flush` calls serialise on the `inner`
932    /// mutex anyway.
933    compressor: Mutex<zstd::bulk::Compressor<'static>>,
934    /// Pooled zstd decompression context — the read-side mirror of
935    /// [`compressor`](Self::compressor). Allocated once at
936    /// [`SegmentBuffer::open`] and reused for every subsequent
937    /// [`SegmentBuffer::read_from`] / [`SegmentBuffer::for_each_from`] call.
938    /// Cloud-sync drain loops are read-heavy (draining the buffer is the
939    /// primary workload), so the `DCtx` pooling matters symmetrically to the
940    /// `CCtx` pooling on the write side. Falls back to `zstd::decode_all`
941    /// (fresh `DCtx` per call) only when the frame header lacks a content
942    /// size — the `bulk::Compressor` write path always includes it, so the
943    /// fallback is rare in practice (legacy or externally-written files).
944    decompressor: Mutex<zstd::bulk::Decompressor<'static>>,
945    /// I/O backend. Production uses [`RealStore`] (real filesystem via
946    /// `std::fs`); loom concurrency tests inject a mock backed by
947    /// `loom::sync::Mutex<HashMap<..>>` so `delete_acked` + `append`
948    /// interleavings can be enumerated exhaustively without modelling the
949    /// kernel filesystem. The trait object costs ~5 ns per I/O call
950    /// (negligible next to zstd+CBOR+file I/O) and is constructed internally
951    /// by [`open`](Self::open), so callers never see it. The store is always
952    /// called OUTSIDE the `inner` mutex — see [`flush`](Self::flush) and
953    /// [`delete_acked`](Self::delete_acked) for the lock-release boundaries.
954    store: Arc<dyn store::SegmentStore + Send + Sync>,
955    /// File handle holding the exclusive single-process `flock` on
956    /// `<dir>/.segment-buffer.lock`. Acquired by `open_internal` BEFORE any
957    /// recovery scans or state publication; released by `Drop` (closing the
958    /// fd releases the kernel advisory lock). `None` only when the buffer
959    /// was constructed via the test-only `open_with_store` path, which
960    /// bypasses the lock (loom tests do not model the filesystem and would
961    /// otherwise deadlock on a real lock file inside `loom::model`).
962    ///
963    /// Holding the lock as a `File` rather than via `fs4::FileExt::unlock`
964    /// is intentional: the fd-holds-the-lock model is portable (Linux,
965    /// macOS, Windows) and survives panics automatically — the kernel
966    /// closes the fd on process termination, releasing the lock even if
967    /// `Drop` never runs.
968    lock_file: Option<std::fs::File>,
969    /// Result of the open-time mtime capability probe. `true` when the
970    /// filesystem hosting `dir` updates a file's `mtime` on a sub-second
971    /// write-after-write window (ext4/xfs/btrfs/apfs/ntfs-defaults all
972    /// qualify); `false` when the filesystem pins `mtime` to a constant
973    /// (some FUSE mounts, network filesystems with coarse granularity,
974    /// memoised-overlay filesystems) — comparing `0 == 0` would falsely
975    /// confirm cache validity, so we fall back to today's "cache only
976    /// invalidated by in-process mutations" behavior on such filesystems.
977    ///
978    /// See [`probe_mtime_capability`] for the probe sequence and the
979    /// rationale for why a bare stat comparison without the probe is
980    /// unsafe.
981    mtime_supported: bool,
982    /// Last-observed mtime of `dir`, captured alongside every `scan_cache`
983    /// population. Used by [`scan_segments`](Self::scan_segments) to
984    /// detect external directory manipulation (a backup tool, a manual
985    /// `rm`, an operator quarantining a file) without paying for a full
986    /// readdir on every read. Only consulted when [`mtime_supported`](Self::mtime_supported)
987    /// is `true`; otherwise the cache stays warm until an in-process
988    /// mutation invalidates it.
989    last_dir_mtime: Mutex<Option<std::time::SystemTime>>,
990}
991
992/// `Debug` mirrors the field set of [`BufferStats`] plus the directory path.
993/// It does NOT print the in-memory `unflushed` items (which could be large or
994/// sensitive), so `T` itself is not required to be `Debug`.
995impl<T> std::fmt::Debug for SegmentBuffer<T>
996where
997    T: Serialize + DeserializeOwned + Clone + Send,
998{
999    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1000        let stats = self.stats();
1001        f.debug_struct("SegmentBuffer")
1002            .field("dir", &self.dir)
1003            .field("pending_count", &stats.pending_count)
1004            .field("latest_sequence", &stats.latest_sequence)
1005            .field("head_sequence", &stats.head_sequence)
1006            .field("next_sequence", &stats.next_sequence)
1007            .field("approx_disk_bytes", &stats.approx_disk_bytes)
1008            .field("segment_count", &stats.segment_count)
1009            .field("max_size_bytes", &stats.max_size_bytes)
1010            .field("store_pressure", &stats.store_pressure)
1011            .finish_non_exhaustive()
1012    }
1013}
1014
1015impl<T> SegmentBuffer<T>
1016where
1017    T: Serialize + DeserializeOwned + Clone + Send,
1018{
1019    /// Open (or create) a buffer at `dir`, recovering from any existing
1020    /// segment files.
1021    ///
1022    /// Recovery is **filename-based**: it scans the directory to rebuild
1023    /// `head_seq` / `next_seq` and deletes leftover `.tmp` debris. Segment
1024    /// *contents* are not read until [`read_from`](Self::read_from), so a
1025    /// corrupted segment does not fail here — it fails when read.
1026    ///
1027    /// If you need the recovery summary (segments found, bytes, head/next seq)
1028    /// programmatically, use [`SegmentBuffer::open_with_report`] instead. The
1029    /// same data is logged via `tracing::info!` from this call.
1030    ///
1031    /// # Example
1032    ///
1033    /// ```
1034    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1035    /// use tempfile::tempdir;
1036    ///
1037    /// let dir = tempdir()?;
1038    /// let buf: SegmentBuffer<u64> =
1039    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1040    /// # Ok::<(), Box<dyn std::error::Error>>(())
1041    /// ```
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
1046    pub fn open(dir: impl Into<PathBuf>, config: SegmentConfig) -> Result<Self> {
1047        let (buffer, _report) = Self::open_with_report(dir, config)?;
1048        Ok(buffer)
1049    }
1050
1051    /// Like [`SegmentBuffer::open`], but also returns a [`RecoveryReport`]
1052    /// describing what the recovery scan found on disk.
1053    ///
1054    /// Useful for operational dashboards or migration tools that need to know
1055    /// the on-disk state without re-scanning.
1056    ///
1057    /// # Example
1058    ///
1059    /// ```
1060    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1061    /// use tempfile::tempdir;
1062    ///
1063    /// let dir = tempdir()?;
1064    /// let (buf, report) =
1065    ///     SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
1066    /// assert_eq!(report.segment_count, 0); // fresh dir
1067    /// assert_eq!(report.head_seq, 0);
1068    /// assert_eq!(report.next_seq, 0);
1069    /// # Ok::<(), Box<dyn std::error::Error>>(())
1070    /// ```
1071    ///
1072    /// # Errors
1073    ///
1074    /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
1075    /// Returns [`SegmentError::Locked`] if another process holds the
1076    /// exclusive single-process lock on `<dir>/.segment-buffer.lock`.
1077    pub fn open_with_report(
1078        dir: impl Into<PathBuf>,
1079        config: SegmentConfig,
1080    ) -> Result<(Self, RecoveryReport)> {
1081        let dir = dir.into();
1082        let store: Arc<dyn store::SegmentStore + Send + Sync> =
1083            Arc::new(store::RealStore::new(dir.clone()));
1084        store
1085            .create_dir_all()
1086            .map_err(error::SegmentError::with_dir)?;
1087
1088        // Acquire the single-process lock BEFORE any filename parsing or
1089        // state publication. A second opener on the same directory would
1090        // race on segment filenames, double-deliver, and corrupt
1091        // head_seq/next_seq — fail fast with a typed error instead. The
1092        // lock is held for the lifetime of the returned SegmentBuffer
1093        // (stored in the `lock_file` field); Drop closes the fd, which
1094        // releases the kernel advisory lock.
1095        let lock_path = dir.join(LOCK_FILE_NAME);
1096        let lock_file = std::fs::OpenOptions::new()
1097            .create(true)
1098            .read(true)
1099            .write(true)
1100            .truncate(false)
1101            .open(&lock_path)
1102            .map_err(|source| SegmentError::Io {
1103                site: IoSite::Segment(lock_path.clone()),
1104                source,
1105            })?;
1106        if fs4::FileExt::try_lock(&lock_file).is_err() {
1107            return Err(SegmentError::Locked { path: lock_path });
1108        }
1109        Self::open_internal(dir, config, store, Some(lock_file))
1110    }
1111
1112    /// Open (or create) a buffer with a caller-supplied [`SegmentStore`].
1113    ///
1114    /// Production callers use [`open`](Self::open) (which constructs a
1115    /// [`RealStore`] internally AND acquires the single-process flock).
1116    /// This constructor exists for loom concurrency tests, which inject a
1117    /// mock store backed by `loom::sync::Mutex<HashMap<..>>` so
1118    /// `delete_acked` + `append` interleavings can be enumerated without
1119    /// modelling the kernel filesystem. It does NOT acquire the flock —
1120    /// loom does not model the filesystem, and a real lock file inside
1121    /// `loom::model` would deadlock.
1122    ///
1123    /// Only reachable when the `loom` Cargo feature is enabled. Not part of
1124    /// the stable semver surface.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Returns [`SegmentError::Io`] if `store.create_dir_all()` fails or
1129    /// recovery cannot scan the segment directory.
1130    #[cfg(feature = "loom")]
1131    pub fn open_with_store(
1132        dir: impl Into<PathBuf>,
1133        config: SegmentConfig,
1134        store: Arc<dyn store::SegmentStore + Send + Sync>,
1135    ) -> Result<Self> {
1136        let dir = dir.into();
1137        let (buffer, _report) = Self::open_internal(dir, config, store, None)?;
1138        Ok(buffer)
1139    }
1140
1141    /// Shared constructor used by both the production entry points
1142    /// (`open`/`open_with_report`) and the test-only `open_with_store`.
1143    /// Owns the invariant that the store is constructed before recovery
1144    /// runs, and that `create_dir_all` goes through the store rather than
1145    /// `std::fs` directly. `lock_file` is `Some` for production opens
1146    /// (the flock was acquired by the caller) and `None` for loom-test
1147    /// opens (loom does not model the filesystem).
1148    fn open_internal(
1149        dir: PathBuf,
1150        config: SegmentConfig,
1151        store: Arc<dyn store::SegmentStore + Send + Sync>,
1152        lock_file: Option<std::fs::File>,
1153    ) -> Result<(Self, RecoveryReport)> {
1154        // `create_dir_all` was already run by the caller if it owned the
1155        // store (production path). When the test harness passes a fresh
1156        // store, run it here for symmetry. Idempotent, so a second call is
1157        // a no-op.
1158        store
1159            .create_dir_all()
1160            .map_err(error::SegmentError::with_dir)?;
1161
1162        // Allocate the pooled zstd CCtx once, at the configured compression
1163        // level. This is the allocation whose per-flush memset was 66% of
1164        // `flush` CPU before pooling (flamegraph 2026-07-20). The level is
1165        // fixed for the lifetime of the buffer because `SegmentConfig` is
1166        // consumed by `open` and immutable thereafter.
1167        let compressor = zstd::bulk::Compressor::new(config.compression_level)?;
1168        // Allocate the pooled zstd DCtx once — symmetric to the compressor
1169        // above. Read paths (`read_from`, `for_each_from`) reuse this DCtx
1170        // instead of constructing a fresh one per segment decode.
1171        let decompressor = zstd::bulk::Decompressor::new()?;
1172
1173        // Probe mtime capability: write a sentinel file twice with a short
1174        // sleep, and check whether the kernel updated its mtime. On
1175        // filesystems that pin mtime to a constant (some FUSE, network
1176        // filesystems with coarse granularity), the scan-cache mtime
1177        // guard is unsafe (0 == 0 false-positive) and we fall back to
1178        // today's "cache invalidated only by in-process mutations"
1179        // behavior. The probe runs at open() time so the cost is paid
1180        // once. The ~15ms sleep is well within the granularity of every
1181        // modern local filesystem (ext4/xfs/btrfs/apfs/ntfs all support
1182        // nanosecond mtime); filesystems that fail the probe are exactly
1183        // those where the guard would have been unsafe.
1184        let mtime_supported = probe_mtime_capability(&dir);
1185        let initial_mtime = std::fs::metadata(&dir).and_then(|m| m.modified()).ok();
1186
1187        let buffer = Self {
1188            dir,
1189            config,
1190            inner: Mutex::new(BufferInner {
1191                unflushed: Vec::new(),
1192                next_seq: 0,
1193                head_seq: 0,
1194                last_flush: Instant::now(),
1195            }),
1196            approx_disk_bytes: std::sync::atomic::AtomicU64::new(0),
1197            segment_count: std::sync::atomic::AtomicU64::new(0),
1198            scan_cache: Mutex::new(None),
1199            compressor: Mutex::new(compressor),
1200            decompressor: Mutex::new(decompressor),
1201            store,
1202            lock_file,
1203            mtime_supported,
1204            last_dir_mtime: Mutex::new(initial_mtime),
1205        };
1206
1207        let report = buffer.recover()?;
1208        Ok((buffer, report))
1209    }
1210
1211    // -----------------------------------------------------------------------
1212    // Public API
1213    // -----------------------------------------------------------------------
1214
1215    /// Append an item to the buffer. Assigns the next sequence number and
1216    /// auto-flushes if the batch threshold or interval is reached.
1217    ///
1218    /// Returns the assigned sequence number. The first append returns `0`,
1219    /// and the number increments by 1 for each subsequent append.
1220    ///
1221    /// # Example
1222    ///
1223    /// ```
1224    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1225    /// use tempfile::tempdir;
1226    ///
1227    /// let dir = tempdir()?;
1228    /// let buf: SegmentBuffer<u64> =
1229    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1230    ///
1231    /// assert_eq!(buf.append(1)?, 0);
1232    /// assert_eq!(buf.append(2)?, 1);
1233    /// assert_eq!(buf.append(3)?, 2);
1234    /// # Ok::<(), Box<dyn std::error::Error>>(())
1235    /// ```
1236    ///
1237    /// # Errors
1238    ///
1239    /// Returns an error only when the auto-flush triggered by this append
1240    /// fails to write its segment file ([`SegmentError::Io`],
1241    /// [`SegmentError::Cbor`], or [`SegmentError::Cipher`]). Appends that do
1242    /// not cross the flush threshold never fail.
1243    pub fn append(&self, event: T) -> Result<u64> {
1244        let (should_flush, seq) = {
1245            let mut inner = self.inner.lock();
1246            inner.unflushed.push(event);
1247            inner.next_seq = inner.next_seq.saturating_add(1);
1248            let seq = inner.next_seq.saturating_sub(1);
1249
1250            let should_flush = self
1251                .config
1252                .flush_policy
1253                .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
1254            drop(inner);
1255            (should_flush, seq)
1256        };
1257
1258        if should_flush {
1259            self.flush()?;
1260        }
1261
1262        Ok(seq)
1263    }
1264
1265    /// Flush buffered items to a segment file. No-op if nothing is buffered.
1266    ///
1267    /// Flushing is also triggered automatically by [`append`](Self::append)
1268    /// according to the configured [`FlushPolicy`] (batch threshold, interval,
1269    /// both, or manual). Call this explicitly when you need durability before
1270    /// a known threshold, or when using [`FlushPolicy::Manual`].
1271    ///
1272    /// # Example
1273    ///
1274    /// ```
1275    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1276    /// use tempfile::tempdir;
1277    ///
1278    /// let dir = tempdir()?;
1279    /// let buf: SegmentBuffer<u64> =
1280    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1281    /// buf.append(1)?;
1282    /// buf.append(2)?;
1283    ///
1284    /// buf.flush()?; // items now durable on disk
1285    /// assert_eq!(buf.pending_count(), 2);
1286    /// # Ok::<(), Box<dyn std::error::Error>>(())
1287    /// ```
1288    ///
1289    /// # Errors
1290    ///
1291    /// Returns [`SegmentError::Io`], [`SegmentError::Cbor`], or
1292    /// [`SegmentError::Cipher`] if encoding or writing the segment file fails.
1293    /// A no-op flush (nothing buffered) always succeeds.
1294    pub fn flush(&self) -> Result<()> {
1295        let (events, start_seq, end_seq) = {
1296            let mut inner = self.inner.lock();
1297            inner.last_flush = Instant::now();
1298            if inner.unflushed.is_empty() {
1299                return Ok(());
1300            }
1301            let events = std::mem::take(&mut inner.unflushed);
1302            // Recycle the allocation: the next batch is likely the same size,
1303            // so reserve the old capacity up front instead of forcing
1304            // `append()` to grow the empty Vec back through log2(N) reallocs.
1305            inner.unflushed.reserve(events.capacity());
1306            let count = u64::try_from(events.len()).unwrap_or(u64::MAX);
1307            let end_seq = inner.next_seq.saturating_sub(1);
1308            let start_seq = end_seq.saturating_add(1).saturating_sub(count);
1309            drop(inner);
1310            (events, start_seq, end_seq)
1311        };
1312
1313        let compressed_len = self.write_segment(start_seq, end_seq, &events)?;
1314
1315        // approx_disk_bytes is now an AtomicU64, so flush() no longer needs
1316        // to re-acquire the mutex just to bump one u64.
1317        self.approx_disk_bytes
1318            .fetch_add(compressed_len, std::sync::atomic::Ordering::Relaxed);
1319        self.segment_count
1320            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1321        // A new segment file invalidates the directory-scan cache.
1322        self.invalidate_scan_cache();
1323
1324        debug!(
1325            path = self.segment_path(start_seq, end_seq).display().to_string(),
1326            seq = start_seq,
1327            end_seq,
1328            count = events.len(),
1329            bytes = compressed_len,
1330            "Flushed segment"
1331        );
1332        Ok(())
1333    }
1334
1335    /// Read up to `limit` items starting from `start_seq` (inclusive).
1336    ///
1337    /// Reads from both on-disk segment files and in-memory pending items.
1338    /// Items are returned in ascending sequence order.
1339    ///
1340    /// Passing `limit = 0` returns an empty `Vec` without scanning.
1341    ///
1342    /// # Example
1343    ///
1344    /// ```
1345    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1346    /// use tempfile::tempdir;
1347    ///
1348    /// let dir = tempdir()?;
1349    /// let buf: SegmentBuffer<u64> =
1350    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1351    /// buf.append(10)?;
1352    /// buf.append(20)?;
1353    /// buf.append(30)?;
1354    /// buf.flush()?;
1355    ///
1356    /// let items = buf.read_from(0, 100)?;
1357    /// assert_eq!(items, vec![10, 20, 30]);
1358    ///
1359    /// // start_seq skips already-read items:
1360    /// let tail = buf.read_from(2, 100)?;
1361    /// assert_eq!(tail, vec![30]);
1362    /// # Ok::<(), Box<dyn std::error::Error>>(())
1363    /// ```
1364    ///
1365    /// # Errors
1366    ///
1367    /// Returns [`SegmentError::Io`] if the segment directory cannot be scanned,
1368    /// or [`SegmentError::Cbor`] / [`SegmentError::Cipher`] /
1369    /// [`SegmentError::Integrity`] if a segment file cannot be decoded.
1370    pub fn read_from(&self, start_seq: u64, limit: usize) -> Result<Vec<T>> {
1371        if limit == 0 {
1372            return Ok(Vec::new());
1373        }
1374
1375        let mut result: Vec<T> = Vec::with_capacity(limit.min(1024));
1376
1377        // Phase 1: read from on-disk segments.
1378        let segments = self.scan_segments()?;
1379        for seg in &segments {
1380            if result.len() >= limit {
1381                break;
1382            }
1383            if seg.end < start_seq {
1384                continue;
1385            }
1386
1387            let events = self.read_segment(*seg)?;
1388            let skip = if seg.start < start_seq {
1389                usize::try_from(start_seq.saturating_sub(seg.start)).unwrap_or(usize::MAX)
1390            } else {
1391                0
1392            };
1393
1394            for event in events.into_iter().skip(skip) {
1395                if result.len() >= limit {
1396                    break;
1397                }
1398                result.push(event);
1399            }
1400        }
1401
1402        // Phase 2: read from in-memory pending events.
1403        if result.len() < limit {
1404            let inner = self.inner.lock();
1405            let pending_start = inner
1406                .next_seq
1407                .saturating_sub(u64::try_from(inner.unflushed.len()).unwrap_or(u64::MAX));
1408            for (i, event) in inner.unflushed.iter().enumerate() {
1409                let seq = pending_start.saturating_add(u64::try_from(i).unwrap_or(u64::MAX));
1410                if seq < start_seq {
1411                    continue;
1412                }
1413                if result.len() >= limit {
1414                    break;
1415                }
1416                result.push(event.clone());
1417            }
1418        }
1419
1420        Ok(result)
1421    }
1422
1423    /// Lending-iterator counterpart to [`read_from`](Self::read_from): invoke
1424    /// `f(seq, item)` for up to `limit` items starting at `start_seq`, without
1425    /// materialising them into a `Vec<T>`.
1426    ///
1427    /// This avoids the per-item `Clone` that [`read_from`](Self::read_from)
1428    /// pays for in-memory pending items. On-disk segments still deserialize
1429    /// into a temporary `Vec<T>` per segment (the on-disk format is bytes, not
1430    /// `T`), but items are passed to `f` by reference rather than being
1431    /// re-collected.
1432    ///
1433    /// Returns the number of items the callback was invoked for.
1434    ///
1435    /// # Performance
1436    ///
1437    /// Since the panic-free re-entrancy fix, `for_each_from` snapshots the
1438    /// in-memory pending window under the lock and releases the lock before
1439    /// invoking `f`. Both `for_each_from` and `read_from` therefore clone the
1440    /// in-memory items once and are now roughly equal on the in-memory tail
1441    /// (indicative, measured on master):
1442    ///
1443    /// | Items | `read_from` | `for_each_from` |
1444    /// |-------|-------------|-----------------|
1445    /// | 1,000 | ~23 µs      | ~23 µs          |
1446    /// | 10,000| ~220 µs     | ~197 µs         |
1447    ///
1448    /// `for_each_from` stays marginally cheaper (no owned `Vec<T>` to return and
1449    /// drop) and is the right choice for callback-style consumption. Once
1450    /// on-disk segments dominate, both paths pay the same CBOR+zstd+cipher
1451    /// decode cost per segment.
1452    ///
1453    /// # Re-entrancy
1454    ///
1455    /// The buffer mutex is **never held across `f`**. On-disk items are decoded
1456    /// before the callback, and in-memory pending items are snapshotted under
1457    /// the lock then handed to `f` after the lock is released. Re-entrant calls
1458    /// (e.g. `append`, `stats`, `delete_acked` from a closure that captured an
1459    /// `Arc<SegmentBuffer<T>>`) are therefore safe and cannot deadlock — the
1460    /// public API is panic-free.
1461    ///
1462    /// # Errors
1463    ///
1464    /// Returns `SegmentError::Io` if any on-disk segment in the requested range
1465    /// cannot be read or decoded (corruption, missing file after recovery, cipher
1466    /// failure on an encrypted segment).
1467    ///
1468    /// # Example
1469    ///
1470    /// ```
1471    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1472    /// use tempfile::tempdir;
1473    ///
1474    /// let dir = tempdir()?;
1475    /// let buf: SegmentBuffer<u64> =
1476    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1477    /// for i in 0..5u64 {
1478    ///     buf.append(i * 10)?;
1479    /// }
1480    /// buf.flush()?;
1481    ///
1482    /// let mut sum = 0u64;
1483    /// let count = buf.for_each_from(0, 100, |_seq, item| { sum += *item; })?;
1484    /// assert_eq!(count, 5);
1485    /// assert_eq!(sum, 0 + 10 + 20 + 30 + 40);
1486    /// # Ok::<(), Box<dyn std::error::Error>>(())
1487    /// ```
1488    pub fn for_each_from<F>(&self, start_seq: u64, limit: usize, mut f: F) -> Result<usize>
1489    where
1490        F: FnMut(u64, &T),
1491    {
1492        if limit == 0 {
1493            return Ok(0);
1494        }
1495
1496        let mut visited = 0usize;
1497
1498        // Phase 1: on-disk segments. Items are still deserialized into a per-
1499        // segment Vec<T>, but each is handed to f by reference rather than
1500        // being re-collected into the caller's Vec.
1501        let segments = self.scan_segments()?;
1502        for seg in &segments {
1503            if visited >= limit {
1504                break;
1505            }
1506            if seg.end < start_seq {
1507                continue;
1508            }
1509
1510            let events = self.read_segment(*seg)?;
1511            let skip = if seg.start < start_seq {
1512                usize::try_from(start_seq.saturating_sub(seg.start)).unwrap_or(usize::MAX)
1513            } else {
1514                0
1515            };
1516
1517            for (offset, event) in events.iter().enumerate().skip(skip) {
1518                if visited >= limit {
1519                    break;
1520                }
1521                let seq = seg
1522                    .start
1523                    .saturating_add(u64::try_from(offset).unwrap_or(u64::MAX));
1524                f(seq, event);
1525                visited = visited.saturating_add(1);
1526            }
1527        }
1528
1529        // Phase 2: in-memory pending items. Snapshot the relevant window under
1530        // the lock, then RELEASE the lock before invoking the callback. This
1531        // guarantees the mutex is never held across a user callback, so
1532        // re-entrant calls (append, stats, delete_acked, ...) cannot deadlock
1533        // and the public API is panic-free by construction. The clone is
1534        // bounded by `remaining` items, never the whole backlog.
1535        if visited < limit {
1536            let (base_seq, window): (u64, Vec<T>) = {
1537                let inner = self.inner.lock();
1538                let pending_start = inner
1539                    .next_seq
1540                    .saturating_sub(u64::try_from(inner.unflushed.len()).unwrap_or(u64::MAX));
1541                let skip =
1542                    usize::try_from(start_seq.saturating_sub(pending_start)).unwrap_or(usize::MAX);
1543                let remaining = limit.saturating_sub(visited);
1544                let base = pending_start.saturating_add(u64::try_from(skip).unwrap_or(u64::MAX));
1545                let window = inner
1546                    .unflushed
1547                    .iter()
1548                    .skip(skip)
1549                    .take(remaining)
1550                    .cloned()
1551                    .collect();
1552                drop(inner);
1553                (base, window)
1554            };
1555            for (offset, event) in window.iter().enumerate() {
1556                let seq = base_seq.saturating_add(u64::try_from(offset).unwrap_or(u64::MAX));
1557                f(seq, event);
1558                visited = visited.saturating_add(1);
1559            }
1560        }
1561
1562        Ok(visited)
1563    }
1564
1565    /// Delete all on-disk segment files whose items are fully covered by
1566    /// `acked_seq`.
1567    ///
1568    /// A segment is deleted when its `end_seq <= acked_seq`. Returns the number
1569    /// of segment files removed.
1570    ///
1571    /// # Example
1572    ///
1573    /// ```
1574    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1575    /// use tempfile::tempdir;
1576    ///
1577    /// let dir = tempdir()?;
1578    /// let buf: SegmentBuffer<u64> =
1579    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1580    /// for i in 0..5u64 {
1581    ///     buf.append(i)?;
1582    /// }
1583    /// buf.flush()?;
1584    ///
1585    /// // Consumer has processed sequence 0..=4; acknowledge them:
1586    /// let removed = buf.delete_acked(4)?;
1587    /// assert_eq!(removed, 1); // one segment file deleted
1588    /// assert_eq!(buf.pending_count(), 0);
1589    /// # Ok::<(), Box<dyn std::error::Error>>(())
1590    /// ```
1591    ///
1592    /// # Limitation
1593    ///
1594    /// Acknowledgement only removes **flushed** segment files. Items still held
1595    /// in the in-memory pending batch have no segment file to delete, so they
1596    /// remain readable (and counted by [`SegmentBuffer::pending_count`]) until
1597    /// they are flushed and acknowledged in a later call. `head_seq` is clamped
1598    /// so it never advances past the pending window, keeping the backlog count
1599    /// honest.
1600    ///
1601    /// # Errors
1602    ///
1603    /// Returns [`SegmentError::Io`] if the directory scan or a segment-file
1604    /// removal fails.
1605    pub fn delete_acked(&self, acked_seq: u64) -> Result<usize> {
1606        let segments = self.scan_segments()?;
1607        let mut deleted: usize = 0;
1608        let mut freed_bytes: u64 = 0;
1609        let mut new_head = None;
1610
1611        for seg in &segments {
1612            if seg.end <= acked_seq {
1613                let path = self.segment_path(seg.start, seg.end);
1614                let file_bytes = self.store.segment_size(*seg);
1615                freed_bytes = freed_bytes.saturating_add(file_bytes);
1616                // remove_segment is idempotent on NotFound so concurrent
1617                // delete_acked calls do not race on the same segment file.
1618                // Returns true iff THIS call actually removed the file.
1619                if self.store.remove_segment(*seg)? {
1620                    deleted = deleted.saturating_add(1);
1621                    debug!(
1622                        path = path.display().to_string(),
1623                        seq = seg.start,
1624                        end_seq = seg.end,
1625                        bytes = file_bytes,
1626                        "Deleted acked segment"
1627                    );
1628                }
1629            } else if new_head.is_none() {
1630                new_head = Some(seg.start);
1631            }
1632        }
1633
1634        // Subtract the freed bytes atomically; the lock is still needed for
1635        // head_seq, but approx_disk_bytes can update independently.
1636        self.approx_disk_bytes
1637            .fetch_sub(freed_bytes, std::sync::atomic::Ordering::Relaxed);
1638        self.segment_count.fetch_sub(
1639            u64::try_from(deleted).unwrap_or(u64::MAX),
1640            std::sync::atomic::Ordering::Relaxed,
1641        );
1642        // Deleted segment files invalidate the directory-scan cache.
1643        self.invalidate_scan_cache();
1644
1645        {
1646            let mut inner = self.inner.lock();
1647            // `head_seq` tracks the oldest unacked sequence. Clamp it to the
1648            // start of the in-memory pending window: items still waiting to be
1649            // flushed cannot be acknowledged (there is no segment file to
1650            // delete), so head_seq must not advance past them. Without this
1651            // clamp, acknowledging past a buffer that still holds unflushed
1652            // items would make `pending_count` under-report the real backlog.
1653            let pending_start = inner
1654                .next_seq
1655                .saturating_sub(u64::try_from(inner.unflushed.len()).unwrap_or(u64::MAX));
1656            inner.head_seq = new_head.unwrap_or(inner.next_seq).min(pending_start);
1657        }
1658
1659        if deleted > 0 {
1660            info!(
1661                path = self.dir.display().to_string(),
1662                deleted,
1663                bytes = freed_bytes,
1664                seq = acked_seq,
1665                "Deleted acked segments"
1666            );
1667        }
1668
1669        Ok(deleted)
1670    }
1671
1672    /// The highest sequence number assigned (or 0 if buffer is empty).
1673    ///
1674    /// # Example
1675    ///
1676    /// ```
1677    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1678    /// use tempfile::tempdir;
1679    ///
1680    /// let dir = tempdir()?;
1681    /// let buf: SegmentBuffer<u64> =
1682    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1683    ///
1684    /// assert_eq!(buf.latest_sequence(), 0);
1685    /// buf.append(7)?;
1686    /// assert_eq!(buf.latest_sequence(), 0);
1687    /// buf.append(8)?;
1688    /// assert_eq!(buf.latest_sequence(), 1);
1689    /// # Ok::<(), Box<dyn std::error::Error>>(())
1690    /// ```
1691    ///
1692    #[must_use = "the sequence number is meaningless if discarded"]
1693    pub fn latest_sequence(&self) -> u64 {
1694        let inner = self.inner.lock();
1695        if inner.next_seq == 0 {
1696            0
1697        } else {
1698            inner.next_seq.saturating_sub(1)
1699        }
1700    }
1701
1702    /// Total items waiting in the buffer: on-disk segments **plus** in-memory
1703    /// items not yet flushed to a segment file.
1704    ///
1705    /// "Pending" means **not yet acknowledged**
1706    /// ([`delete_acked`](Self::delete_acked)), not "not yet flushed." A
1707    /// [`flush`](Self::flush) therefore leaves this count unchanged — items
1708    /// merely move from the in-memory tail into on-disk segment files, where
1709    /// they stay pending until acknowledged. The count decreases only when
1710    /// `delete_acked` removes acknowledged segments.
1711    ///
1712    /// The split between the on-disk and in-memory portions is internal and
1713    /// not exposed separately by the public API.
1714    ///
1715    /// Equivalent to `latest_sequence() - head_seq + 1` when non-empty, 0 when
1716    /// empty.
1717    ///
1718    /// # Example
1719    ///
1720    /// ```
1721    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1722    /// use tempfile::tempdir;
1723    ///
1724    /// let dir = tempdir()?;
1725    /// let buf: SegmentBuffer<u64> =
1726    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1727    ///
1728    /// assert_eq!(buf.pending_count(), 0);
1729    /// buf.append(1)?;
1730    /// buf.append(2)?;
1731    /// assert_eq!(buf.pending_count(), 2);
1732    /// buf.flush()?;
1733    /// assert_eq!(buf.pending_count(), 2); // still pending until acked
1734    /// buf.delete_acked(1)?;
1735    /// assert_eq!(buf.pending_count(), 0);
1736    /// # Ok::<(), Box<dyn std::error::Error>>(())
1737    /// ```
1738    ///
1739    #[must_use = "the backlog size is meaningless if discarded"]
1740    pub fn pending_count(&self) -> u64 {
1741        let inner = self.inner.lock();
1742        inner.next_seq.saturating_sub(inner.head_seq)
1743    }
1744
1745    /// Standard [`len`](#method.len) alias for [`pending_count`](Self::pending_count).
1746    ///
1747    /// Provided so `SegmentBuffer` reads like a normal collection at the call
1748    /// site (`buf.len()`, `buf.is_empty()`). Same value as `pending_count()`,
1749    /// kept as `u64` because the buffer is proven beyond `usize::MAX` on
1750    /// 32-bit targets (597M+ events in monitor365).
1751    ///
1752    /// # Example
1753    ///
1754    /// ```
1755    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1756    /// use tempfile::tempdir;
1757    ///
1758    /// let dir = tempdir()?;
1759    /// let buf: SegmentBuffer<u64> =
1760    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1761    /// assert!(buf.is_empty());
1762    /// buf.append(7)?;
1763    /// assert_eq!(buf.len(), 1);
1764    /// assert!(!buf.is_empty());
1765    /// # Ok::<(), Box<dyn std::error::Error>>(())
1766    /// ```
1767    #[must_use = "the backlog size is meaningless if discarded"]
1768    pub fn len(&self) -> u64 {
1769        self.pending_count()
1770    }
1771
1772    /// `true` when there are no items waiting in the buffer (on-disk or
1773    /// in-memory). Equivalent to `pending_count() == 0`.
1774    ///
1775    /// # Example
1776    ///
1777    /// ```
1778    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1779    /// use tempfile::tempdir;
1780    ///
1781    /// let dir = tempdir()?;
1782    /// let buf: SegmentBuffer<u64> =
1783    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1784    /// assert!(buf.is_empty());
1785    /// # Ok::<(), Box<dyn std::error::Error>>(())
1786    /// ```
1787    #[must_use = "the emptiness flag is meaningless if discarded"]
1788    pub fn is_empty(&self) -> bool {
1789        self.pending_count() == 0
1790    }
1791
1792    /// Disk usage pressure as a value between 0.0 and 1.0.
1793    ///
1794    /// Use this to implement your own admission/backpressure policy (e.g.
1795    /// reject low-priority items above 0.90, reject standard items above 0.95).
1796    /// Returns 0.0 when `max_size_bytes == 0` (limit disabled).
1797    ///
1798    /// # Example
1799    ///
1800    /// ```
1801    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1802    /// use tempfile::tempdir;
1803    ///
1804    /// let dir = tempdir()?;
1805    /// let mut cfg = SegmentConfig::default();
1806    /// cfg.max_size_bytes = 1000; // tiny limit so pressure is observable
1807    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), cfg)?;
1808    ///
1809    /// assert!(buf.store_pressure() < 0.1);
1810    /// # Ok::<(), Box<dyn std::error::Error>>(())
1811    /// ```
1812    #[must_use = "the pressure value is meaningless if discarded"]
1813    #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
1814    pub fn store_pressure(&self) -> f32 {
1815        // store_pressure only needs approx_disk_bytes + max_size_bytes —
1816        // neither requires the mutex. Read the atomic directly to avoid
1817        // contending with append/flush.
1818        if self.config.max_size_bytes == 0 {
1819            return 0.0;
1820        }
1821        let bytes = self
1822            .approx_disk_bytes
1823            .load(std::sync::atomic::Ordering::Relaxed);
1824        (bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1825    }
1826
1827    /// True when disk usage exceeds 90% of the configured limit.
1828    ///
1829    /// Convenience wrapper around `store_pressure() > 0.9`.
1830    ///
1831    /// # Example
1832    ///
1833    /// ```
1834    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1835    /// use tempfile::tempdir;
1836    ///
1837    /// let dir = tempdir()?;
1838    /// let buf: SegmentBuffer<u64> =
1839    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1840    ///
1841    /// assert!(!buf.is_overloaded());
1842    /// # Ok::<(), Box<dyn std::error::Error>>(())
1843    /// ```
1844    #[must_use = "the overload flag is meaningless if discarded"]
1845    pub fn is_overloaded(&self) -> bool {
1846        self.store_pressure() > 0.9
1847    }
1848
1849    /// Capture a consistent snapshot of buffer state under a single lock.
1850    ///
1851    /// Cheaper and more consistent than calling
1852    /// [`pending_count`](Self::pending_count),
1853    /// [`latest_sequence`](Self::latest_sequence),
1854    /// [`store_pressure`](Self::store_pressure) etc. individually (which each
1855    /// take the mutex and could observe a flush/delete between calls).
1856    ///
1857    /// # Performance
1858    ///
1859    /// Micro-benchmarked in `benches/bench_stats.rs` (run with
1860    /// `cargo bench --bench bench_stats --features encryption`):
1861    ///
1862    /// | Operation                                  | Measured time (median, typical run) |
1863    /// |--------------------------------------------|--------------------------------------|
1864    /// | `stats()` (single lock, 8-field snapshot)  | ~12 ns                               |
1865    /// | 3 individual accessors (`pending_count` + `latest_sequence` + `store_pressure`) | ~31 ns |
1866    ///
1867    /// So `stats()` is roughly **2.5× cheaper than 3 individual accessors**
1868    /// while also being atomic — torn reads between calls are impossible.
1869    /// Numbers are from the benchmark machine and fluctuate with hardware;
1870    /// the relative ratio is the durable claim.
1871    ///
1872    /// # Example
1873    ///
1874    /// ```
1875    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1876    /// use tempfile::tempdir;
1877    ///
1878    /// let dir = tempdir()?;
1879    /// let buf: SegmentBuffer<u64> =
1880    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1881    /// buf.append(1)?;
1882    /// buf.append(2)?;
1883    ///
1884    /// let snapshot = buf.stats();
1885    /// assert_eq!(snapshot.pending_count, 2);
1886    /// assert_eq!(snapshot.next_sequence, 2);
1887    /// assert_eq!(snapshot.segment_count, 0); // nothing flushed yet
1888    /// assert!(snapshot.store_pressure < 0.01);
1889    /// # Ok::<(), Box<dyn std::error::Error>>(())
1890    /// ```
1891    ///
1892    #[must_use = "the snapshot is meaningless if discarded"]
1893    #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
1894    pub fn stats(&self) -> BufferStats {
1895        let inner = self.inner.lock();
1896        let pending_count = inner.next_seq.saturating_sub(inner.head_seq);
1897        let latest_sequence = if inner.next_seq == 0 {
1898            0
1899        } else {
1900            inner.next_seq.saturating_sub(1)
1901        };
1902        // Load the atomic OUTSIDE the mutex's critical section logic — the
1903        // value is approximate by design, so a torn read between this load
1904        // and the inner.lock() is acceptable.
1905        let approx_disk_bytes = self
1906            .approx_disk_bytes
1907            .load(std::sync::atomic::Ordering::Relaxed);
1908        let segment_count = self
1909            .segment_count
1910            .load(std::sync::atomic::Ordering::Relaxed);
1911        let store_pressure = if self.config.max_size_bytes == 0 {
1912            0.0
1913        } else {
1914            (approx_disk_bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1915        };
1916        BufferStats {
1917            pending_count,
1918            latest_sequence,
1919            head_sequence: inner.head_seq,
1920            next_sequence: inner.next_seq,
1921            approx_disk_bytes,
1922            segment_count,
1923            max_size_bytes: self.config.max_size_bytes,
1924            store_pressure,
1925        }
1926    }
1927
1928    /// The directory this buffer reads from and writes segment files to.
1929    ///
1930    /// Useful for operators that need to inspect, archive, or quarantine the
1931    /// segment directory without parsing it out of [`Debug`](std::fmt::Debug).
1932    ///
1933    /// # Example
1934    ///
1935    /// ```
1936    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1937    /// use tempfile::tempdir;
1938    ///
1939    /// let dir = tempdir()?;
1940    /// let buf: SegmentBuffer<u64> =
1941    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1942    /// assert_eq!(buf.path(), dir.path());
1943    /// # Ok::<(), Box<dyn std::error::Error>>(())
1944    /// ```
1945    #[must_use = "the path is meaningless if discarded"]
1946    #[allow(clippy::missing_const_for_fn)]
1947    pub fn path(&self) -> &std::path::Path {
1948        &self.dir
1949    }
1950
1951    /// The [`SegmentConfig`] this buffer was opened with.
1952    ///
1953    /// Returned by reference so callers can inspect the flush policy, disk
1954    /// ceiling, compression level, and cipher presence without re-deriving
1955    /// them. The config is immutable for the lifetime of the buffer.
1956    ///
1957    /// # Example
1958    ///
1959    /// ```
1960    /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
1961    /// use tempfile::tempdir;
1962    ///
1963    /// let dir = tempdir()?;
1964    /// let config = SegmentConfig::builder()
1965    ///     .flush_at_batch_size(128)
1966    ///     .build();
1967    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
1968    /// match &buf.config().flush_policy {
1969    ///     FlushPolicy::Batch(n) => println!("flushing at {n} items"),
1970    ///     _ => {}
1971    /// }
1972    /// # Ok::<(), Box<dyn std::error::Error>>(())
1973    /// ```
1974    #[must_use = "the config is meaningless if discarded"]
1975    pub const fn config(&self) -> &SegmentConfig {
1976        &self.config
1977    }
1978
1979    /// Re-stat the segment directory and store the authoritative total as
1980    /// [`BufferStats::approx_disk_bytes`].
1981    ///
1982    /// [`BufferStats::approx_disk_bytes`] is updated incrementally on every
1983    /// flush/delete/recover, so it is accurate as long as only this buffer
1984    /// touches the directory. If an external process (backup, compaction,
1985    /// manual cleanup) adds or removes segment files, the cached value drifts.
1986    /// This method recomputes it from a directory scan.
1987    ///
1988    /// Returns the new total so callers can observe the delta without a
1989    /// second call to [`stats`](Self::stats).
1990    ///
1991    /// # Example
1992    ///
1993    /// ```
1994    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1995    /// use tempfile::tempdir;
1996    ///
1997    /// let dir = tempdir()?;
1998    /// let buf: SegmentBuffer<u64> =
1999    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
2000    /// buf.append(1)?;
2001    /// buf.flush()?;
2002    ///
2003    /// // Simulate an external process truncating a segment file to zero bytes.
2004    /// for entry in std::fs::read_dir(dir.path())? {
2005    ///     let _ = std::fs::write(entry?.path(), b"");
2006    /// }
2007    ///
2008    /// let synced = buf.sync_disk_bytes()?;
2009    /// assert_eq!(synced, 0, "external truncation should be reflected");
2010    /// # Ok::<(), Box<dyn std::error::Error>>(())
2011    /// ```
2012    ///
2013    /// # Errors
2014    ///
2015    /// Returns [`SegmentError::Io`] if the directory cannot be read.
2016    pub fn sync_disk_bytes(&self) -> Result<u64> {
2017        let segments = self.scan_segments()?;
2018        let total: u64 = segments.iter().map(|s| self.store.segment_size(*s)).sum();
2019        self.approx_disk_bytes
2020            .store(total, std::sync::atomic::Ordering::Relaxed);
2021        self.segment_count.store(
2022            u64::try_from(segments.len()).unwrap_or(u64::MAX),
2023            std::sync::atomic::Ordering::Relaxed,
2024        );
2025        Ok(total)
2026    }
2027
2028    /// On-demand size distribution of the on-disk segment files.
2029    ///
2030    /// Scans the segment directory, stats every segment file, and returns
2031    /// the min / max / mean / p50 / p90 byte-size distribution as a
2032    /// [`SegmentSizeStats`]. This is the tuning primitive for
2033    /// [`FlushPolicy::Batch`]: it answers "are my segments the size I expect,
2034    /// or is the batch size producing too many tiny files / too few huge
2035    /// ones?"
2036    ///
2037    /// Like [`sync_disk_bytes`](Self::sync_disk_bytes), this is an
2038    /// `O(n_segments)` directory scan performed outside the buffer mutex.
2039    /// It is an observability query: call it from a metrics path or an
2040    /// on-demand tuning check, not the append hot path. The scan reuses the
2041    /// same `scan_segments` cache (with `mtime` invalidation) as every other
2042    /// directory-derived read, so a burst of
2043    /// [`stats`](Self::stats) / [`sync_disk_bytes`](Self::sync_disk_bytes) /
2044    /// [`segment_size_stats`](Self::segment_size_stats) calls shares one
2045    /// physical directory read.
2046    ///
2047    /// This method is a **pure query**: it does not mutate the buffer's
2048    /// cached counters. To recalibrate [`BufferStats::approx_disk_bytes`]
2049    /// and [`BufferStats::segment_count`] against the real directory, call
2050    /// [`sync_disk_bytes`](Self::sync_disk_bytes) separately.
2051    ///
2052    /// # Example
2053    ///
2054    /// ```
2055    /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
2056    /// use tempfile::tempdir;
2057    ///
2058    /// let dir = tempdir()?;
2059    /// let config = SegmentConfig::builder()
2060    ///     .flush_policy(FlushPolicy::Manual)
2061    ///     .build();
2062    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
2063    /// for i in 0..100u64 { buf.append(i)?; }
2064    /// buf.flush()?;
2065    ///
2066    /// let sizes = buf.segment_size_stats()?;
2067    /// assert_eq!(sizes.count, 1);
2068    /// assert!(sizes.max_bytes > 0);
2069    /// assert_eq!(sizes.min_bytes, sizes.max_bytes); // single segment
2070    /// # Ok::<(), Box<dyn std::error::Error>>(())
2071    /// ```
2072    ///
2073    /// # Errors
2074    ///
2075    /// Returns [`SegmentError::Io`] if the segment directory cannot be
2076    /// scanned.
2077    #[must_use = "the size distribution is meaningless if discarded"]
2078    pub fn segment_size_stats(&self) -> Result<SegmentSizeStats> {
2079        let segments = self.scan_segments()?;
2080        let mut sizes: Vec<u64> = segments
2081            .iter()
2082            .map(|s| self.store.segment_size(*s))
2083            .collect();
2084        if sizes.is_empty() {
2085            return Ok(SegmentSizeStats {
2086                count: 0,
2087                min_bytes: 0,
2088                max_bytes: 0,
2089                mean_bytes: 0,
2090                p50_bytes: 0,
2091                p90_bytes: 0,
2092            });
2093        }
2094        sizes.sort_unstable();
2095        let count = u64::try_from(sizes.len()).unwrap_or(u64::MAX);
2096        let total: u64 = sizes.iter().copied().fold(0u64, u64::saturating_add);
2097        let mean_bytes = total.checked_div(count).unwrap_or(0);
2098        Ok(SegmentSizeStats {
2099            count,
2100            min_bytes: sizes.first().copied().unwrap_or(0),
2101            max_bytes: sizes.last().copied().unwrap_or(0),
2102            mean_bytes,
2103            p50_bytes: Self::percentile_of_sorted(&sizes, 50),
2104            p90_bytes: Self::percentile_of_sorted(&sizes, 90),
2105        })
2106    }
2107
2108    /// Nearest-rank percentile of a non-empty, ascending-sorted slice.
2109    ///
2110    /// `pct` is in `0..=100`. The value returned is always one of the actual
2111    /// elements of `sorted`, never an interpolation: the 1-based rank is
2112    /// `clamp(ceil(pct / 100 · n), 1, n)`. Empty input returns `0`. Used by
2113    /// [`segment_size_stats`](Self::segment_size_stats); kept as a private
2114    /// associated fn so the nearest-rank contract lives next to its only
2115    /// caller and is cross-checked by the property test via an independent
2116    /// float implementation.
2117    fn percentile_of_sorted(sorted: &[u64], pct: u32) -> u64 {
2118        let n = sorted.len();
2119        if n == 0 {
2120            return 0;
2121        }
2122        let n_u64 = u64::try_from(n).unwrap_or(u64::MAX);
2123        let pct = u64::from(pct);
2124        // rank = ceil(pct/100 · n), computed as ceil(a / 100) = (a + 99) / 100.
2125        // `checked_div` keeps the strict `arithmetic_side_effects` lint happy.
2126        let scaled = pct.saturating_mul(n_u64);
2127        let rank = scaled.saturating_add(99).checked_div(100).unwrap_or(n_u64);
2128        let rank = rank.clamp(1, n_u64);
2129        let idx = usize::try_from(rank.saturating_sub(1)).unwrap_or(0);
2130        sorted.get(idx).copied().unwrap_or(0)
2131    }
2132
2133    /// Append a batch of items under a single lock acquisition.
2134    ///
2135    /// Each item receives the next contiguous sequence number. Returns the
2136    /// last sequence number assigned (matching the contract of
2137    /// [`append`](Self::append)); the full range is
2138    /// `[last - count + 1, last]` where `count` is the number of items the
2139    /// iterator yielded.
2140    ///
2141    /// # Batch vs streaming semantics
2142    ///
2143    /// All items are accumulated under a single lock acquisition, then the
2144    /// flush policy is checked **once** at the end. This gives true atomic
2145    /// batch semantics: either the entire batch lands in the buffer or the
2146    /// error propagates. Callers who want per-item auto-flush semantics
2147    /// (flush at every `batch_size` threshold) should call
2148    /// [`append`](Self::append) in a loop instead — `append_all` is
2149    /// optimized for the "load this batch atomically" use case and avoids
2150    /// paying the lock-acquisition cost per item.
2151    ///
2152    /// # Example
2153    ///
2154    /// ```
2155    /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
2156    /// use tempfile::tempdir;
2157    ///
2158    /// let dir = tempdir()?;
2159    /// let config = SegmentConfig::builder()
2160    ///     .flush_policy(FlushPolicy::Manual)
2161    ///     .build();
2162    /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
2163    ///
2164    /// let last = buf.append_all([10u64, 20, 30, 40])?;
2165    /// assert_eq!(last, 3); // 0-based: items got seqs 0, 1, 2, 3
2166    /// assert_eq!(buf.pending_count(), 4);
2167    /// # Ok::<(), Box<dyn std::error::Error>>(())
2168    /// ```
2169    ///
2170    /// # Errors
2171    ///
2172    /// Returns [`SegmentError::Io`] if a flush triggered by the batch fails.
2173    pub fn append_all<I>(&self, items: I) -> Result<u64>
2174    where
2175        I: IntoIterator<Item = T>,
2176    {
2177        let (should_flush, last_seq, count) = {
2178            let mut inner = self.inner.lock();
2179            let mut count = 0u64;
2180            let mut last_seq = inner.next_seq.saturating_sub(1);
2181            for item in items {
2182                inner.unflushed.push(item);
2183                inner.next_seq = inner.next_seq.wrapping_add(1);
2184                last_seq = inner.next_seq.saturating_sub(1);
2185                count = count.saturating_add(1);
2186            }
2187            if count == 0 {
2188                // Empty iterator: no-op, return current last seq (or 0).
2189                return Ok(inner.next_seq.saturating_sub(1));
2190            }
2191            let should_flush = self
2192                .config
2193                .flush_policy
2194                .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
2195            drop(inner);
2196            (should_flush, last_seq, count)
2197        };
2198        debug_assert!(count > 0);
2199        if should_flush {
2200            self.flush()?;
2201        }
2202        Ok(last_seq)
2203    }
2204
2205    /// Owned-item iterator over buffer contents starting at `start_seq`.
2206    ///
2207    /// Equivalent to [`read_from`](Self::read_from) but yields `(seq, item)`
2208    /// pairs one at a time so callers can write `for (seq, item) in
2209    /// buf.iter_from(start, limit)?` and chain standard
2210    /// [`Iterator`] combinators (`.take`, `.filter`, `.map`, …).
2211    ///
2212    /// This is a *materialising* iterator: items are loaded eagerly up to
2213    /// `limit` (memory cost `O(limit)`) via [`read_from`](Self::read_from).
2214    /// [`for_each_from`](Self::for_each_from) offers the same items through a
2215    /// callback instead of an owned `Iterator`; since the panic-free
2216    /// re-entrancy fix it no longer holds the mutex across the callback and is
2217    /// marginally cheaper than `read_from` (no returned `Vec<T>` to drop). The
2218    /// two coexist because no stable-Rust `Iterator` trait can currently
2219    /// express "yield `&T` from `&mut self`" without pre-collecting.
2220    ///
2221    /// # Re-entrancy
2222    ///
2223    /// The iterator borrows the buffer for `'a` but holds no buffer mutex
2224    /// across `next` calls (items are materialised eagerly). Re-entrant
2225    /// `&self` calls are therefore safe while the iterator is live; the
2226    /// lifetime tie is purely about borrow validity.
2227    ///
2228    /// # Example
2229    ///
2230    /// ```
2231    /// use segment_buffer::{SegmentBuffer, SegmentConfig};
2232    /// use tempfile::tempdir;
2233    ///
2234    /// let dir = tempdir()?;
2235    /// let buf: SegmentBuffer<u64> =
2236    ///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
2237    /// for i in 0..5u64 { buf.append(i * 10)?; }
2238    /// buf.flush()?;
2239    ///
2240    /// // `for` loop with owned items + seq numbers:
2241    /// let mut seen = Vec::new();
2242    /// for (seq, item) in buf.iter_from(0, 100)? {
2243    ///     seen.push((seq, item));
2244    /// }
2245    /// assert_eq!(seen, vec![
2246    ///     (0, 0), (1, 10), (2, 20), (3, 30), (4, 40),
2247    /// ]);
2248    /// # Ok::<(), Box<dyn std::error::Error>>(())
2249    /// ```
2250    ///
2251    /// # Errors
2252    ///
2253    /// Returns [`SegmentError`] if the directory scan or any segment decode
2254    /// fails.
2255    pub fn iter_from(&self, start_seq: u64, limit: usize) -> Result<SegmentIter<'_, T>> {
2256        if limit == 0 {
2257            return Ok(SegmentIter {
2258                inner: Vec::new().into_iter(),
2259                _phantom: std::marker::PhantomData,
2260            });
2261        }
2262
2263        // Materialise the items by calling the zero-copy lending path. This
2264        // keeps the sequence-number computation in one place: `for_each_from`
2265        // derives each seq from the segment's `start` or the pending-window
2266        // base, so the returned pairs are correct even when `start_seq` falls
2267        // inside a deleted segment (a gap that `read_from` legitimately skips).
2268        let mut indexed: Vec<(u64, T)> = Vec::with_capacity(limit.min(1024));
2269        self.for_each_from(start_seq, limit, |seq, item| {
2270            indexed.push((seq, item.clone()));
2271        })?;
2272
2273        Ok(SegmentIter {
2274            inner: indexed.into_iter(),
2275            _phantom: std::marker::PhantomData,
2276        })
2277    }
2278
2279    // -----------------------------------------------------------------------
2280    // Internal helpers
2281    // -----------------------------------------------------------------------
2282
2283    /// Rebuild in-memory state (`head_seq`, `next_seq`, `approx_disk_bytes`,
2284    /// `segment_count`, and the `scan_cache`) from the on-disk segment files.
2285    ///
2286    /// # Concurrency: open-time only
2287    ///
2288    /// This is **private and called exactly once, inside [`open`](Self::open)/
2289    /// [`open_with_store`](Self::open_with_store)/[`open_with_report`](Self::open_with_report),
2290    /// before the buffer is returned to the caller.** Because the buffer is
2291    /// not shared across threads until after construction completes, `recover`
2292    /// can never run concurrently with `read_from`, `flush`, or `delete_acked`
2293    /// — there is no scan-cache/recovery interleaving window to test or guard.
2294    /// The scan-cache races that DO exist (a `read_from`'s `scan_segments`
2295    /// racing a concurrent `flush`/`delete_acked`) are covered by the loom
2296    /// scan-cache tests in `tests/loom.rs` and the `HookedStore` TOCTOU test
2297    /// in `src/tests.rs`.
2298    fn recover(&self) -> Result<RecoveryReport> {
2299        let removed_tmp_files = self.store.clean_tmp()?;
2300
2301        let segments = self.scan_segments()?;
2302
2303        // All store access (sizing each segment) happens BEFORE the mutex is
2304        // taken. The lock is held only long enough to publish the rebuilt
2305        // in-memory state, honouring the invariant that the mutex is never
2306        // held across I/O.
2307        let total_bytes: u64 = segments.iter().map(|s| self.store.segment_size(*s)).sum();
2308
2309        let (head_seq, next_seq) = match (segments.first(), segments.last()) {
2310            (Some(first), Some(last)) => (first.start, last.end.saturating_add(1)),
2311            _ => (0, 0),
2312        };
2313
2314        let segment_count = segments.len();
2315        {
2316            let mut inner = self.inner.lock();
2317            inner.head_seq = head_seq;
2318            inner.next_seq = next_seq;
2319        }
2320        // Store the recovered disk-bytes total into the atomic directly.
2321        self.approx_disk_bytes
2322            .store(total_bytes, std::sync::atomic::Ordering::Relaxed);
2323        self.segment_count.store(
2324            u64::try_from(segment_count).unwrap_or(u64::MAX),
2325            std::sync::atomic::Ordering::Relaxed,
2326        );
2327        // Recovery just scanned the directory; populate the cache so the
2328        // first read_from/delete_acked after open does not re-scan.
2329        *self.scan_cache.lock() = Some(segments);
2330
2331        info!(
2332            path = self.dir.display().to_string(),
2333            segments = segment_count,
2334            seq = head_seq,
2335            end_seq = next_seq,
2336            bytes = total_bytes,
2337            removed_tmp = removed_tmp_files,
2338            "Segment buffer recovered"
2339        );
2340
2341        Ok(RecoveryReport {
2342            segment_count,
2343            head_seq,
2344            next_seq,
2345            disk_bytes: total_bytes,
2346            removed_tmp_files,
2347        })
2348    }
2349
2350    fn write_segment(&self, start: u64, end: u64, events: &[T]) -> Result<u64> {
2351        let path = self.segment_path(start, end);
2352        let range = segment::SegmentRange::new(start, end);
2353        // Lock the pooled compressor for the duration of the encode. The
2354        // mutex is uncontended in practice (see field doc) and the lock is
2355        // NOT held across the store's `write_atomic` call below —
2356        // `encode_segment` returns bytes before any I/O begins.
2357        let mut compressor = self.compressor.lock();
2358        let bytes = segment::encode_segment(
2359            self.config.cipher.as_deref(),
2360            &mut compressor,
2361            &path,
2362            events,
2363        )?;
2364        drop(compressor);
2365        self.store
2366            .write_atomic(range, &bytes, self.config.durability)
2367            .map_err(|e| e.with_path(&path))
2368    }
2369
2370    fn read_segment(&self, seg: segment::SegmentRange) -> Result<Vec<T>> {
2371        let path = self.segment_path(seg.start, seg.end);
2372        let raw = self.store.read_bytes(seg).map_err(|e| e.with_path(&path))?;
2373        let mut decompressor = self.decompressor.lock();
2374        segment::decode_segment(
2375            self.config.cipher.as_deref(),
2376            &mut decompressor,
2377            &raw,
2378            &path,
2379        )
2380        .map_err(|e| e.with_path(&path))
2381    }
2382
2383    fn scan_segments(&self) -> Result<Vec<segment::SegmentRange>> {
2384        // Cache hit: clone under the cache lock and return — UNLESS the
2385        // directory mtime has moved since the cache was populated (which
2386        // signals an external mutation: backup tool, manual rm, operator
2387        // quarantine, etc.). The mtime guard is only consulted when the
2388        // open-time capability probe confirmed the filesystem actually
2389        // updates mtime — on filesystems that pin mtime to a constant,
2390        // comparing 0 == 0 would falsely confirm validity, so we skip the
2391        // check entirely on those.
2392        {
2393            let cache = self.scan_cache.lock();
2394            if let Some(ref segments) = *cache {
2395                if !self.mtime_supported || !self.dir_mtime_changed() {
2396                    return Ok(segments.clone());
2397                }
2398                // mtime moved → fall through to re-scan, replacing the cache.
2399            }
2400        }
2401        // Cache miss: scan via the store, then publish under the cache lock.
2402        //
2403        // The directory mtime is captured BEFORE the scan, not after. A
2404        // segment rename that lands during the readdir would otherwise pair a
2405        // post-rename mtime with a pre-rename (stale) segment list in the
2406        // cache: the mtime guard would then see "no change" and keep serving
2407        // the stale list, breaking the "a retry sees them" guarantee. With a
2408        // pre-scan mtime, any mutation during the scan leaves the cached mtime
2409        // stale, so the next call re-scans and observes the new segment. This
2410        // only helps on filesystems where mtime is meaningful (see
2411        // `mtime_supported`); on others the explicit `invalidate_scan_cache`
2412        // called by every on-disk mutation is the sole defence.
2413        let pre_scan_mtime = std::fs::metadata(&self.dir).and_then(|m| m.modified()).ok();
2414        let segments = self
2415            .store
2416            .scan()
2417            .map_err(error::SegmentError::with_dir)
2418            .map_err(|e| e.with_path(&self.dir))?;
2419        let mut cache = self.scan_cache.lock();
2420        *cache = Some(segments.clone());
2421        drop(cache);
2422        *self.last_dir_mtime.lock() = pre_scan_mtime;
2423        Ok(segments)
2424    }
2425
2426    /// Stat the directory's mtime and compare against the last-cached
2427    /// value. `true` means the directory was touched externally and the
2428    /// scan cache should be invalidated. Cheap (`stat` is one syscall;
2429    /// `readdir` is many).
2430    fn dir_mtime_changed(&self) -> bool {
2431        let Ok(current) = std::fs::metadata(&self.dir).and_then(|m| m.modified()) else {
2432            return true; // directory unreadable → safer to re-scan
2433        };
2434        let cached = *self.last_dir_mtime.lock();
2435        cached.is_none_or(|prev| prev != current)
2436    }
2437
2438    /// Invalidate the scan cache. Called by every on-disk mutation
2439    /// (`flush`, `delete_acked`, `recover`).
2440    fn invalidate_scan_cache(&self) {
2441        let mut cache = self.scan_cache.lock();
2442        *cache = None;
2443    }
2444
2445    fn segment_path(&self, start: u64, end: u64) -> PathBuf {
2446        self.dir.join(segment::filename(start, end))
2447    }
2448}
2449
2450/// Owned-item iterator over buffer contents, yielding `(seq, item)` pairs.
2451///
2452/// Returned by [`SegmentBuffer::iter_from`]. Materialises up to `limit`
2453/// items eagerly (memory cost `O(limit)`); for a lending iterator that
2454/// passes in-memory items by reference without cloning, use
2455/// [`SegmentBuffer::for_each_from`].
2456///
2457/// The iterator borrows the buffer for `'a`. Like
2458/// [`SegmentBuffer::for_each_from`] it is re-entrancy-safe: items are
2459/// materialised eagerly (no buffer mutex held across `next` calls).
2460///
2461/// # Example
2462///
2463/// ```
2464/// use segment_buffer::{SegmentBuffer, SegmentConfig};
2465/// use tempfile::tempdir;
2466///
2467/// let dir = tempdir()?;
2468/// let buf: SegmentBuffer<u64> =
2469///     SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
2470/// buf.append(7)?;
2471/// buf.append(8)?;
2472/// buf.flush()?;
2473///
2474/// let collected: Vec<u64> = buf.iter_from(0, 100)?
2475///     .map(|(_seq, item)| item)
2476///     .collect();
2477/// assert_eq!(collected, vec![7, 8]);
2478/// # Ok::<(), Box<dyn std::error::Error>>(())
2479/// ```
2480pub struct SegmentIter<'a, T> {
2481    inner: std::vec::IntoIter<(u64, T)>,
2482    // Tie the iterator's lifetime to the buffer borrow so callers can't
2483    // outlive the buffer. The buffer mutex is never held across `next` calls
2484    // (items are materialised eagerly), so re-entrant `&self` calls are safe
2485    // while the iterator is live; the lifetime tie is purely about borrow
2486    // validity.
2487    _phantom: std::marker::PhantomData<&'a SegmentBuffer<T>>,
2488}
2489
2490impl<T> Iterator for SegmentIter<'_, T> {
2491    type Item = (u64, T);
2492
2493    fn next(&mut self) -> Option<Self::Item> {
2494        self.inner.next()
2495    }
2496
2497    fn size_hint(&self) -> (usize, Option<usize>) {
2498        self.inner.size_hint()
2499    }
2500}
2501
2502impl<T> std::iter::FusedIterator for SegmentIter<'_, T> {}
2503
2504impl<T> Drop for SegmentBuffer<T> {
2505    /// Releases the single-process flock by explicitly calling `unlock` and
2506    /// then dropping the lock file handle. The kernel would release the
2507    /// advisory lock on fd close anyway, but the explicit call makes the
2508    /// release point diagnosable in a flamegraph (vs. waiting for `File`'s
2509    /// own `Drop` to run somewhere in the field-tear-down sequence).
2510    ///
2511    /// Deliberately no `T: Serialize + ...` bound: `Drop` impls must match
2512    /// the struct's bounds (Rust rule E0367), and the struct itself has no
2513    /// bounds — the bound lives on the API-impl block. The lock-release
2514    /// logic doesn't touch `T` at all, so no bound is needed here.
2515    fn drop(&mut self) {
2516        if let Some(lock_file) = self.lock_file.take() {
2517            // Best-effort unlock: if it fails (kernel EINTR, already closed,
2518            // etc.) there is nothing useful to do — the fd is about to be
2519            // dropped, which releases the lock unconditionally. Suppress the
2520            // unused-result warning; we already have the strong guarantee.
2521            let _ = fs4::FileExt::unlock(&lock_file);
2522            drop(lock_file);
2523        }
2524    }
2525}
2526
2527/// Probe whether the filesystem at `dir` updates a file's mtime on a
2528/// sub-second write-after-write window.
2529///
2530/// Writes a sentinel file twice with a ~15ms sleep between, then compares
2531/// the kernel-reported mtime. Modern local filesystems (ext4/xfs/btrfs/
2532/// apfs/ntfs) all qualify; some FUSE mounts, network filesystems with
2533/// coarse granularity, and memoised-overlay filesystems pin mtime to a
2534/// constant and would fail the probe.
2535///
2536/// Returns `false` on ANY failure (write error, stat error, mtime
2537/// unchanged) — the caller treats a `false` as "do not consult mtime when
2538/// validating the scan cache" (the cache stays warm until an in-process
2539/// mutation invalidates it). This is the safe default: comparing two
2540/// `0 == 0` mtimes would falsely confirm cache validity on a no-mtime
2541/// filesystem, silently serving stale data forever.
2542fn probe_mtime_capability(dir: &std::path::Path) -> bool {
2543    let sentinel = dir.join(".segment-buffer.mtime-probe");
2544    let _ = std::fs::write(&sentinel, b"a");
2545    let t1 = std::fs::metadata(&sentinel).and_then(|m| m.modified()).ok();
2546    std::thread::sleep(std::time::Duration::from_millis(15));
2547    let _ = std::fs::write(&sentinel, b"b");
2548    let t2 = std::fs::metadata(&sentinel).and_then(|m| m.modified()).ok();
2549    let _ = std::fs::remove_file(&sentinel);
2550    matches!((t1, t2), (Some(a), Some(b)) if a != b)
2551}
2552
2553// ---------------------------------------------------------------------------
2554// Static thread-safety assertion
2555// ---------------------------------------------------------------------------
2556
2557// `SegmentBuffer<T>` is documented as MPMC-safe via `parking_lot::Mutex`. This
2558// fails to compile if anyone ever introduces a non-`Send`/`Sync` field on
2559// `SegmentBuffer` or `BufferInner` (e.g. an `Rc`), turning the documented
2560// thread-safety guarantee into a compile-time contract instead of a comment.
2561const _: () = {
2562    const fn assert_send_sync<T: Send + Sync>() {}
2563    assert_send_sync::<SegmentBuffer<()>>();
2564};
2565
2566#[cfg(test)]
2567mod tests;
2568
2569#[cfg(test)]
2570mod property_tests;
2571
2572// Each example file is embedded as a doc-test so `cargo test --doc` gives
2573// execution coverage on top of the compilation coverage from
2574// `cargo test --examples`. The `concat!` wraps the raw file content in a
2575// code fence so rustdoc treats it as compilable+runnable Rust.
2576#[cfg(doctest)]
2577mod example_doctests {
2578    #[doc = concat!("```rust\n", include_str!("../examples/basic_usage.rs"), "\n```")]
2579    const BASIC_USAGE: () = ();
2580
2581    #[doc = concat!("```rust\n", include_str!("../examples/backpressure.rs"), "\n```")]
2582    const BACKPRESSURE: () = ();
2583
2584    #[doc = concat!("```rust\n", include_str!("../examples/crash_recovery.rs"), "\n```")]
2585    const CRASH_RECOVERY: () = ();
2586
2587    #[doc = concat!("```rust\n", include_str!("../examples/mpmc.rs"), "\n```")]
2588    const MPMC: () = ();
2589
2590    #[cfg(feature = "encryption")]
2591    #[doc = concat!("```rust\n", include_str!("../examples/encrypted.rs"), "\n```")]
2592    const ENCRYPTED: () = ();
2593}