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