Skip to main content

segment_buffer/
lib.rs

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