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