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