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