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