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