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