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