segment_buffer/lib.rs
1//! Durable bounded queue backed by zstd-compressed CBOR segment files.
2//!
3//! Items are accumulated in memory, flushed as zstd-compressed CBOR batches
4//! to `seg_{start:012}_{end:012}.zst` files, and deleted once the consumer
5//! acknowledges receipt via [`SegmentBuffer::delete_acked`].
6//!
7//! The buffer is generic over any `T: Serialize + DeserializeOwned + Clone + Send + 'static`.
8//! Crash recovery is filename-based: scanning the directory rebuilds `head_seq`
9//! and `next_seq` without any WAL or metadata database.
10//!
11//! # Example
12//!
13//! ```no_run
14//! use segment_buffer::{SegmentBuffer, SegmentConfig};
15//! use serde::{Serialize, Deserialize};
16//!
17//! #[derive(Serialize, Deserialize, Clone)]
18//! struct MyItem { id: u64 }
19//!
20//! let buffer = SegmentBuffer::<MyItem>::open("/tmp/my-queue", SegmentConfig::default())?;
21//! let seq = buffer.append(MyItem { id: 1 })?;
22//! let items = buffer.read_from(0, 100)?;
23//! # Ok::<(), Box<dyn std::error::Error>>(())
24//! ```
25//!
26//! For the full README — install, quickstart, encryption, backpressure,
27//! comparison table, and performance notes — see the
28//! [project README on GitHub](https://github.com/LarsArtmann/segment-buffer#segment-buffer)
29//! or [docs.rs](https://docs.rs/segment-buffer).
30
31#![warn(missing_docs)]
32#![doc = include_str!("../README.md")]
33
34mod cipher;
35mod error;
36mod segment;
37
38#[cfg(feature = "encryption")]
39pub use cipher::AesGcmCipher;
40pub use cipher::{CipherError, SegmentCipher};
41pub use error::{Result, SegmentError};
42
43/// Hidden internals exposed for fuzz targets and deep integration tests.
44/// NOT part of the public API; do not depend on these from external code.
45/// Stability is not guaranteed — these may change or disappear in any release.
46#[doc(hidden)]
47pub mod fuzz_hooks {
48 pub use crate::segment::{
49 filename, parse_filename, unwrap_envelope, wrap_envelope, SegmentRange,
50 };
51}
52
53use std::fs;
54use std::path::PathBuf;
55use std::time::Instant;
56
57use parking_lot::Mutex;
58use serde::de::DeserializeOwned;
59use serde::Serialize;
60use tracing::{debug, info};
61
62use segment::SegmentRange;
63
64/// When to auto-flush pending items from memory to a segment file.
65///
66/// Passed to [`SegmentConfig`] via its `flush_policy` field. Replaces the
67/// pre-v0.4.0 silent combination of two separate fields (`max_batch_events`
68/// and `flush_interval_secs`) that OR'd together without telling the caller
69/// which trigger fired.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum FlushPolicy {
73 /// Flush as soon as `batch_size` items are buffered. No interval trigger.
74 Batch(usize),
75 /// Flush as soon as `interval` has elapsed since the last flush. No batch
76 /// trigger.
77 Interval(std::time::Duration),
78 /// Flush when EITHER `batch_size` items are buffered OR `interval` has
79 /// elapsed since the last flush — whichever fires first. This is the
80 /// pre-v0.4.0 default behavior.
81 BatchOrInterval {
82 /// In-memory item count threshold.
83 batch_size: usize,
84 /// Max time between flushes.
85 interval: std::time::Duration,
86 },
87 /// Never auto-flush. The caller must call [`SegmentBuffer::flush`]
88 /// explicitly to make appends durable. Useful for tests and for callers
89 /// that want absolute control over write amplification.
90 Manual,
91}
92
93impl Default for FlushPolicy {
94 fn default() -> Self {
95 // Matches the pre-v0.4.0 SegmentConfig::default: 256 events or 5s.
96 FlushPolicy::BatchOrInterval {
97 batch_size: 256,
98 interval: std::time::Duration::from_secs(5),
99 }
100 }
101}
102
103impl FlushPolicy {
104 /// Returns `true` when the policy says the buffer should flush now.
105 ///
106 /// `pending_len` is the current length of the in-memory `unflushed` Vec;
107 /// `time_since_last_flush` is `last_flush.elapsed()`.
108 fn should_flush(&self, pending_len: usize, time_since_last_flush: std::time::Duration) -> bool {
109 match self {
110 FlushPolicy::Batch(n) => pending_len >= *n,
111 FlushPolicy::Interval(d) => time_since_last_flush >= *d,
112 FlushPolicy::BatchOrInterval {
113 batch_size,
114 interval,
115 } => pending_len >= *batch_size || time_since_last_flush >= *interval,
116 FlushPolicy::Manual => false,
117 }
118 }
119}
120
121/// Configuration knobs for [`SegmentBuffer`].
122///
123/// This struct is `#[non_exhaustive]`: new fields may be added in any release
124/// without breaking semver. Construct via [`SegmentConfig::builder()`] and then
125/// mutate the public fields you care about, or use [`SegmentConfig::default()`]
126/// directly:
127///
128/// ```
129/// use segment_buffer::SegmentConfig;
130///
131/// let mut config = SegmentConfig::default();
132/// config.max_size_bytes = 1024 * 1024;
133/// ```
134#[non_exhaustive]
135pub struct SegmentConfig {
136 /// When to auto-flush pending items. See [`FlushPolicy`] for the options.
137 pub flush_policy: FlushPolicy,
138 /// Max total disk usage before the buffer reports overload pressure (default: 10 GB).
139 pub max_size_bytes: u64,
140 /// zstd compression level (1-22; 3 is fast with a good ratio).
141 pub compression_level: i32,
142 /// Optional cipher for encrypting segment files at rest. When `None`,
143 /// segments are written as plaintext zstd+CBOR.
144 pub cipher: Option<Box<dyn SegmentCipher>>,
145}
146
147impl std::fmt::Debug for SegmentConfig {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("SegmentConfig")
150 .field("flush_policy", &self.flush_policy)
151 .field("max_size_bytes", &self.max_size_bytes)
152 .field("compression_level", &self.compression_level)
153 .field("cipher", &self.cipher.as_ref().map(|_| "[set]"))
154 .finish()
155 }
156}
157
158impl Default for SegmentConfig {
159 fn default() -> Self {
160 Self {
161 flush_policy: FlushPolicy::default(),
162 max_size_bytes: 10 * 1024 * 1024 * 1024,
163 compression_level: 3,
164 cipher: None,
165 }
166 }
167}
168
169/// Ergonomic builder for [`SegmentConfig`].
170///
171/// `SegmentConfig` is `#[non_exhaustive]`, so direct struct-literal
172/// construction is forbidden outside the crate. The builder is the
173/// recommended way for callers to override one or two fields without
174/// re-typing every default.
175///
176/// ```
177/// use segment_buffer::{FlushPolicy, SegmentConfig};
178/// use std::time::Duration;
179///
180/// let config = SegmentConfig::builder()
181/// .flush_policy(FlushPolicy::Batch(64))
182/// .compression_level(6)
183/// .build();
184/// assert_eq!(config.flush_policy, FlushPolicy::Batch(64));
185/// assert_eq!(config.compression_level, 6);
186/// // Untouched fields fall back to Default.
187/// assert_eq!(config.max_size_bytes, 10 * 1024 * 1024 * 1024);
188/// ```
189#[derive(Debug)]
190pub struct SegmentConfigBuilder {
191 inner: SegmentConfig,
192}
193
194impl SegmentConfigBuilder {
195 /// Override the auto-flush policy. See [`FlushPolicy`] for variants.
196 pub fn flush_policy(mut self, policy: FlushPolicy) -> Self {
197 self.inner.flush_policy = policy;
198 self
199 }
200
201 /// Convenience: install a `FlushPolicy::Batch(batch_size)`.
202 pub fn flush_at_batch_size(self, batch_size: usize) -> Self {
203 self.flush_policy(FlushPolicy::Batch(batch_size))
204 }
205
206 /// Convenience: install a `FlushPolicy::Interval(interval)`.
207 pub fn flush_at_interval(self, interval: std::time::Duration) -> Self {
208 self.flush_policy(FlushPolicy::Interval(interval))
209 }
210
211 /// Convenience: install a `FlushPolicy::BatchOrInterval { .. }` with both
212 /// triggers set.
213 pub fn flush_at_batch_or_interval(
214 self,
215 batch_size: usize,
216 interval: std::time::Duration,
217 ) -> Self {
218 self.flush_policy(FlushPolicy::BatchOrInterval {
219 batch_size,
220 interval,
221 })
222 }
223
224 /// Convenience: install a `FlushPolicy::Manual` (no auto-flush).
225 pub fn flush_manually(self) -> Self {
226 self.flush_policy(FlushPolicy::Manual)
227 }
228
229 /// Override the disk-usage ceiling that triggers `is_overloaded()`.
230 pub fn max_size_bytes(mut self, max_size_bytes: u64) -> Self {
231 self.inner.max_size_bytes = max_size_bytes;
232 self
233 }
234
235 /// Override the zstd compression level (1–22; 3 is fast with a good ratio).
236 pub fn compression_level(mut self, compression_level: i32) -> Self {
237 self.inner.compression_level = compression_level;
238 self
239 }
240
241 /// Install a [`SegmentCipher`] so segment payloads are encrypted at rest.
242 pub fn cipher(mut self, cipher: Box<dyn SegmentCipher>) -> Self {
243 self.inner.cipher = Some(cipher);
244 self
245 }
246
247 /// Materialise the configured [`SegmentConfig`].
248 pub fn build(self) -> SegmentConfig {
249 self.inner
250 }
251}
252
253impl SegmentConfig {
254 /// Begin a builder. Every field starts at [`SegmentConfig::default`];
255 /// chain setter calls to override the ones you care about.
256 #[must_use = "the builder is meaningless if discarded"]
257 pub fn builder() -> SegmentConfigBuilder {
258 SegmentConfigBuilder {
259 inner: SegmentConfig::default(),
260 }
261 }
262}
263
264/// Point-in-time snapshot of buffer state, captured atomically under a single
265/// lock acquisition so all fields are mutually consistent.
266///
267/// Returned by [`SegmentBuffer::stats`]. Useful for metrics endpoints or
268/// dashboards that need to observe multiple values without paying for several
269/// lock/unlock round-trips (and risking a torn read between calls).
270///
271/// This struct is `#[non_exhaustive]`: new fields may be added in any release
272/// without breaking semver. It is constructed internally by [`SegmentBuffer::stats`];
273/// callers read fields via dot-syntax or pattern-match with `..` only.
274#[derive(Debug, Clone)]
275#[non_exhaustive]
276pub struct BufferStats {
277 /// Items waiting in the buffer (on-disk + in-memory pending).
278 /// Same value as [`SegmentBuffer::pending_count`].
279 pub pending_count: u64,
280 /// Highest sequence number assigned (or `0` if the buffer is empty).
281 /// Same value as [`SegmentBuffer::latest_sequence`].
282 pub latest_sequence: u64,
283 /// Oldest unacknowledged sequence number (`head_seq`).
284 pub head_sequence: u64,
285 /// Next sequence number that will be assigned by the next successful
286 /// [`SegmentBuffer::append`] (`next_seq`).
287 pub next_sequence: u64,
288 /// Approximate total bytes used by segment files on disk. Decreases when
289 /// [`SegmentBuffer::delete_acked`] removes files.
290 pub approx_disk_bytes: u64,
291 /// Configured ceiling on disk usage (`max_size_bytes`). `0` disables the
292 /// limit; in that case [`store_pressure`](Self::store_pressure) is `0.0`.
293 pub max_size_bytes: u64,
294 /// `approx_disk_bytes / max_size_bytes`, clamped to `[0.0, 1.0]`.
295 /// `0.0` when no limit is configured.
296 pub store_pressure: f32,
297}
298
299/// Summary of the recovery scan performed by [`SegmentBuffer::open`].
300///
301/// Returned by [`SegmentBuffer::open_with_report`] for programmatic
302/// introspection. The same data is logged via `tracing` from
303/// [`SegmentBuffer::open`]; this struct is for callers that want to inspect
304/// it without parsing logs.
305///
306/// All fields are snapshots taken during recovery — they may be stale by the
307/// time the caller reads them, because other threads can append/flush/delete
308/// immediately after `open` returns. For a live view, use
309/// [`SegmentBuffer::stats`].
310///
311/// # Recovering over a populated directory
312///
313/// ```
314/// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
315/// use tempfile::tempdir;
316///
317/// let dir = tempdir()?;
318///
319/// // First instance: write three items, flush, drop.
320/// {
321/// let config = SegmentConfig::builder()
322/// .flush_policy(FlushPolicy::Manual)
323/// .build();
324/// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
325/// for i in 0..3u64 { buf.append(i)?; }
326/// buf.flush()?;
327/// }
328///
329/// // Re-open: recovery must find one segment covering seqs 0..=2.
330/// let (buf, report) =
331/// SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
332/// assert_eq!(report.segment_count, 1);
333/// assert_eq!(report.head_seq, 0);
334/// assert_eq!(report.next_seq, 3);
335/// assert!(report.disk_bytes > 0, "flushed segment must have nonzero size");
336/// assert_eq!(report.removed_tmp_files, 0);
337/// # Ok::<(), Box<dyn std::error::Error>>(())
338/// ```
339#[derive(Debug, Clone, PartialEq, Eq)]
340#[non_exhaustive]
341pub struct RecoveryReport {
342 /// Number of valid segment files found on disk during recovery.
343 pub segment_count: usize,
344 /// Oldest sequence number recovered (the `start` of the first segment),
345 /// or `0` when the directory was empty.
346 pub head_seq: u64,
347 /// Next sequence number that will be assigned by the next
348 /// [`SegmentBuffer::append`] (the `end + 1` of the last segment), or `0`
349 /// when the directory was empty.
350 pub next_seq: u64,
351 /// Total bytes of all recovered segment files (sum of file sizes).
352 pub disk_bytes: u64,
353 /// Number of `.tmp` debris files removed by recovery's cleanup step.
354 pub removed_tmp_files: usize,
355}
356
357struct BufferInner<T> {
358 /// Items buffered in memory, not yet written to a segment file. Drained by
359 /// [`SegmentBuffer::flush`] and rebuilt empty on crash recovery (unflushed
360 /// items do not survive a crash by design).
361 unflushed: Vec<T>,
362 next_seq: u64,
363 head_seq: u64,
364 last_flush: Instant,
365}
366
367/// Durable bounded queue of `T` backed by compressed segment files.
368///
369/// Thread-safe via `parking_lot::Mutex`. All file I/O is synchronous. The mutex
370/// is never held across an async boundary because there are no await points.
371///
372/// Create with [`SegmentBuffer::open`], supplying the directory and config.
373pub struct SegmentBuffer<T> {
374 dir: PathBuf,
375 config: SegmentConfig,
376 inner: Mutex<BufferInner<T>>,
377 /// Total bytes used by segment files on disk. Updated atomically on
378 /// flush/delete/recover so `flush()` does not need to re-acquire the
379 /// mutex just to bump one u64. Read by `store_pressure` and `stats`.
380 /// Deliberately approximate: the real number can drift if files are
381 /// touched outside this crate, so it is suitable for backpressure
382 /// signalling and metrics, NOT for billing.
383 approx_disk_bytes: std::sync::atomic::AtomicU64,
384 /// Cache of `scan_segments()`. `None` means stale (must re-scan); `Some`
385 /// means a flush/`delete_acked` has not touched the directory since the
386 /// last scan. The cache is invalidated by every on-disk mutation
387 /// (`flush`, `delete_acked`, `recover`) and never goes stale any other
388 /// way — operators who manipulate the directory behind the buffer's back
389 /// get the directory scan cost back.
390 scan_cache: Mutex<Option<Vec<segment::SegmentRange>>>,
391 /// Re-entrancy guard for [`SegmentBuffer::for_each_from`]. Set to `true`
392 /// for the duration of a `for_each_from` call (including across the user
393 /// callback `F`); every other `&self` method that takes `inner.lock()`
394 /// asserts this is `false` and panics with a clear message otherwise.
395 ///
396 /// This converts the silent deadlock of re-entering the buffer from inside
397 /// a `for_each_from` callback into an immediate, diagnosable panic. The
398 /// atomic load costs ~1 ns per locking op — negligible next to the mutex.
399 iteration_in_progress: std::sync::atomic::AtomicBool,
400}
401
402/// `Debug` mirrors the field set of [`BufferStats`] plus the directory path.
403/// It does NOT print the in-memory `unflushed` items (which could be large or
404/// sensitive), so `T` itself is not required to be `Debug`.
405impl<T> std::fmt::Debug for SegmentBuffer<T>
406where
407 T: Serialize + DeserializeOwned + Clone + Send + 'static,
408{
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 let stats = self.stats();
411 f.debug_struct("SegmentBuffer")
412 .field("dir", &self.dir)
413 .field("pending_count", &stats.pending_count)
414 .field("latest_sequence", &stats.latest_sequence)
415 .field("head_sequence", &stats.head_sequence)
416 .field("next_sequence", &stats.next_sequence)
417 .field("approx_disk_bytes", &stats.approx_disk_bytes)
418 .field("max_size_bytes", &stats.max_size_bytes)
419 .field("store_pressure", &stats.store_pressure)
420 .finish()
421 }
422}
423
424impl<T> SegmentBuffer<T>
425where
426 T: Serialize + DeserializeOwned + Clone + Send + 'static,
427{
428 /// Open (or create) a buffer at `dir`, recovering from any existing
429 /// segment files.
430 ///
431 /// Recovery is **filename-based**: it scans the directory to rebuild
432 /// `head_seq` / `next_seq` and deletes leftover `.tmp` debris. Segment
433 /// *contents* are not read until [`read_from`](Self::read_from), so a
434 /// corrupted segment does not fail here — it fails when read.
435 ///
436 /// If you need the recovery summary (segments found, bytes, head/next seq)
437 /// programmatically, use [`SegmentBuffer::open_with_report`] instead. The
438 /// same data is logged via `tracing::info!` from this call.
439 ///
440 /// # Example
441 ///
442 /// ```
443 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
444 /// use tempfile::tempdir;
445 ///
446 /// let dir = tempdir()?;
447 /// let buf: SegmentBuffer<u64> =
448 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
449 /// # Ok::<(), Box<dyn std::error::Error>>(())
450 /// ```
451 ///
452 /// # Errors
453 ///
454 /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
455 pub fn open(dir: impl Into<PathBuf>, config: SegmentConfig) -> Result<Self> {
456 let (buffer, _report) = Self::open_with_report(dir, config)?;
457 Ok(buffer)
458 }
459
460 /// Like [`SegmentBuffer::open`], but also returns a [`RecoveryReport`]
461 /// describing what the recovery scan found on disk.
462 ///
463 /// Useful for operational dashboards or migration tools that need to know
464 /// the on-disk state without re-scanning.
465 ///
466 /// # Example
467 ///
468 /// ```
469 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
470 /// use tempfile::tempdir;
471 ///
472 /// let dir = tempdir()?;
473 /// let (buf, report) =
474 /// SegmentBuffer::<u64>::open_with_report(dir.path(), SegmentConfig::default())?;
475 /// assert_eq!(report.segment_count, 0); // fresh dir
476 /// assert_eq!(report.head_seq, 0);
477 /// assert_eq!(report.next_seq, 0);
478 /// # Ok::<(), Box<dyn std::error::Error>>(())
479 /// ```
480 ///
481 /// # Errors
482 ///
483 /// Returns [`SegmentError::Io`] if the directory cannot be created or read.
484 pub fn open_with_report(
485 dir: impl Into<PathBuf>,
486 config: SegmentConfig,
487 ) -> Result<(Self, RecoveryReport)> {
488 let dir = dir.into();
489 fs::create_dir_all(&dir)?;
490
491 let buffer = Self {
492 dir,
493 config,
494 inner: Mutex::new(BufferInner {
495 unflushed: Vec::new(),
496 next_seq: 0,
497 head_seq: 0,
498 last_flush: Instant::now(),
499 }),
500 approx_disk_bytes: std::sync::atomic::AtomicU64::new(0),
501 scan_cache: Mutex::new(None),
502 iteration_in_progress: std::sync::atomic::AtomicBool::new(false),
503 };
504
505 let report = buffer.recover()?;
506 Ok((buffer, report))
507 }
508
509 // -----------------------------------------------------------------------
510 // Public API
511 // -----------------------------------------------------------------------
512
513 /// Append an item to the buffer. Assigns the next sequence number and
514 /// auto-flushes if the batch threshold or interval is reached.
515 ///
516 /// Returns the assigned sequence number. The first append returns `0`,
517 /// and the number increments by 1 for each subsequent append.
518 ///
519 /// # Example
520 ///
521 /// ```
522 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
523 /// use tempfile::tempdir;
524 ///
525 /// let dir = tempdir()?;
526 /// let buf: SegmentBuffer<u64> =
527 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
528 ///
529 /// assert_eq!(buf.append(1)?, 0);
530 /// assert_eq!(buf.append(2)?, 1);
531 /// assert_eq!(buf.append(3)?, 2);
532 /// # Ok::<(), Box<dyn std::error::Error>>(())
533 /// ```
534 pub fn append(&self, event: T) -> Result<u64> {
535 self.assert_not_reentered("append");
536 let (should_flush, seq) = {
537 let mut inner = self.inner.lock();
538 inner.unflushed.push(event);
539 inner.next_seq += 1;
540 let seq = inner.next_seq - 1;
541
542 let should_flush = self
543 .config
544 .flush_policy
545 .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
546 (should_flush, seq)
547 };
548
549 if should_flush {
550 self.flush()?;
551 }
552
553 Ok(seq)
554 }
555
556 /// Flush buffered items to a segment file. No-op if nothing is buffered.
557 ///
558 /// Flushing is also triggered automatically by [`append`](Self::append)
559 /// according to the configured [`FlushPolicy`] (batch threshold, interval,
560 /// both, or manual). Call this explicitly when you need durability before
561 /// a known threshold, or when using [`FlushPolicy::Manual`].
562 ///
563 /// # Example
564 ///
565 /// ```
566 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
567 /// use tempfile::tempdir;
568 ///
569 /// let dir = tempdir()?;
570 /// let buf: SegmentBuffer<u64> =
571 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
572 /// buf.append(1)?;
573 /// buf.append(2)?;
574 ///
575 /// buf.flush()?; // items now durable on disk
576 /// assert_eq!(buf.pending_count(), 2);
577 /// # Ok::<(), Box<dyn std::error::Error>>(())
578 /// ```
579 pub fn flush(&self) -> Result<()> {
580 self.assert_not_reentered("flush");
581 let (events, start_seq, end_seq) = {
582 let mut inner = self.inner.lock();
583 inner.last_flush = Instant::now();
584 if inner.unflushed.is_empty() {
585 return Ok(());
586 }
587 let events = std::mem::take(&mut inner.unflushed);
588 let count = events.len() as u64;
589 let end_seq = inner.next_seq - 1;
590 let start_seq = end_seq + 1 - count;
591 (events, start_seq, end_seq)
592 };
593
594 let compressed_len = self.write_segment(start_seq, end_seq, &events)?;
595
596 // approx_disk_bytes is now an AtomicU64, so flush() no longer needs
597 // to re-acquire the mutex just to bump one u64.
598 self.approx_disk_bytes
599 .fetch_add(compressed_len, std::sync::atomic::Ordering::Relaxed);
600 // A new segment file invalidates the directory-scan cache.
601 self.invalidate_scan_cache();
602
603 debug!(
604 path = self.segment_path(start_seq, end_seq).display().to_string(),
605 seq = start_seq,
606 end_seq,
607 count = events.len(),
608 bytes = compressed_len,
609 "Flushed segment"
610 );
611 Ok(())
612 }
613
614 /// Read up to `limit` items starting from `start_seq` (inclusive).
615 ///
616 /// Reads from both on-disk segment files and in-memory pending items.
617 /// Items are returned in ascending sequence order.
618 ///
619 /// Passing `limit = 0` returns an empty `Vec` without scanning.
620 ///
621 /// # Example
622 ///
623 /// ```
624 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
625 /// use tempfile::tempdir;
626 ///
627 /// let dir = tempdir()?;
628 /// let buf: SegmentBuffer<u64> =
629 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
630 /// buf.append(10)?;
631 /// buf.append(20)?;
632 /// buf.append(30)?;
633 /// buf.flush()?;
634 ///
635 /// let items = buf.read_from(0, 100)?;
636 /// assert_eq!(items, vec![10, 20, 30]);
637 ///
638 /// // start_seq skips already-read items:
639 /// let tail = buf.read_from(2, 100)?;
640 /// assert_eq!(tail, vec![30]);
641 /// # Ok::<(), Box<dyn std::error::Error>>(())
642 /// ```
643 pub fn read_from(&self, start_seq: u64, limit: usize) -> Result<Vec<T>> {
644 self.assert_not_reentered("read_from");
645 if limit == 0 {
646 return Ok(Vec::new());
647 }
648
649 let mut result: Vec<T> = Vec::with_capacity(limit.min(1024));
650
651 // Phase 1: read from on-disk segments.
652 let segments = self.scan_segments()?;
653 for seg in &segments {
654 if result.len() >= limit {
655 break;
656 }
657 if seg.end < start_seq {
658 continue;
659 }
660
661 let events = self.read_segment(*seg)?;
662 let skip = if seg.start < start_seq {
663 (start_seq - seg.start) as usize
664 } else {
665 0
666 };
667
668 for event in events.into_iter().skip(skip) {
669 if result.len() >= limit {
670 break;
671 }
672 result.push(event);
673 }
674 }
675
676 // Phase 2: read from in-memory pending events.
677 if result.len() < limit {
678 let inner = self.inner.lock();
679 let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
680 for (i, event) in inner.unflushed.iter().enumerate() {
681 let seq = pending_start + i as u64;
682 if seq < start_seq {
683 continue;
684 }
685 if result.len() >= limit {
686 break;
687 }
688 result.push(event.clone());
689 }
690 }
691
692 Ok(result)
693 }
694
695 /// Lending-iterator counterpart to [`read_from`](Self::read_from): invoke
696 /// `f(seq, item)` for up to `limit` items starting at `start_seq`, without
697 /// materialising them into a `Vec<T>`.
698 ///
699 /// This avoids the per-item `Clone` that [`read_from`](Self::read_from)
700 /// pays for in-memory pending items. On-disk segments still deserialize
701 /// into a temporary `Vec<T>` per segment (the on-disk format is bytes, not
702 /// `T`), but items are passed to `f` by reference rather than being
703 /// re-collected.
704 ///
705 /// Returns the number of items the callback was invoked for.
706 ///
707 /// # Performance
708 ///
709 /// Micro-benchmarked in `benches/bench_read_vs_for_each.rs` against
710 /// in-memory pending items (no segment files):
711 ///
712 /// | Items | `read_from` | `for_each_from` | Speedup |
713 /// |-------|-------------|-----------------|---------|
714 /// | 1,000 | ~26 µs | ~1.2 µs | ~21× |
715 /// | 10,000| ~200 µs | ~10 µs | ~20× |
716 ///
717 /// The speedup shrinks toward zero once on-disk segments dominate, because
718 /// both paths pay the same CBOR+zstd+cipher decode cost per segment — the
719 /// clone saving only applies to the in-memory tail.
720 ///
721 /// # Re-entrancy contract
722 ///
723 /// The buffer mutex is held across `f` while iterating the in-memory
724 /// pending items. Calling **any** other `&self` method on `SegmentBuffer`
725 /// from inside `f` would deadlock (`parking_lot::Mutex` is not reentrant).
726 /// To make this footgun impossible to hit silently, every other method
727 /// asserts it is not being re-entered from inside a `for_each_from`
728 /// callback and **panics with a clear message** if it is. The callback
729 /// receives only `(seq, &T)`, which gives no way to reach the buffer, but
730 /// a closure that captures a clone of the `Arc<SegmentBuffer<T>>` can
731 /// still attempt re-entry — and will now get an immediate, diagnosable
732 /// panic instead of a silent hang.
733 ///
734 /// # Example
735 ///
736 /// ```
737 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
738 /// use tempfile::tempdir;
739 ///
740 /// let dir = tempdir()?;
741 /// let buf: SegmentBuffer<u64> =
742 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
743 /// for i in 0..5u64 {
744 /// buf.append(i * 10)?;
745 /// }
746 /// buf.flush()?;
747 ///
748 /// let mut sum = 0u64;
749 /// let count = buf.for_each_from(0, 100, |_seq, item| { sum += *item; })?;
750 /// assert_eq!(count, 5);
751 /// assert_eq!(sum, 0 + 10 + 20 + 30 + 40);
752 /// # Ok::<(), Box<dyn std::error::Error>>(())
753 /// ```
754 pub fn for_each_from<F>(&self, start_seq: u64, limit: usize, mut f: F) -> Result<usize>
755 where
756 F: FnMut(u64, &T),
757 {
758 if limit == 0 {
759 return Ok(0);
760 }
761
762 // Mark iteration in progress for the entire call. Every other locking
763 // method asserts the flag is false and panics with a clear message,
764 // converting the silent deadlock (parking_lot::Mutex is not reentrant)
765 // into an immediate, diagnosable failure. The guard clears the flag on
766 // scope exit, including during panic unwinding from `f`.
767 self.iteration_in_progress
768 .store(true, std::sync::atomic::Ordering::Relaxed);
769 let _guard = IterationGuard(&self.iteration_in_progress);
770
771 let mut visited = 0usize;
772
773 // Phase 1: on-disk segments. Items are still deserialized into a per-
774 // segment Vec<T>, but each is handed to f by reference rather than
775 // being re-collected into the caller's Vec.
776 let segments = self.scan_segments()?;
777 for seg in &segments {
778 if visited >= limit {
779 break;
780 }
781 if seg.end < start_seq {
782 continue;
783 }
784
785 let events = self.read_segment(*seg)?;
786 let skip = if seg.start < start_seq {
787 (start_seq - seg.start) as usize
788 } else {
789 0
790 };
791
792 for (offset, event) in events.iter().enumerate().skip(skip) {
793 if visited >= limit {
794 break;
795 }
796 let seq = seg.start + offset as u64;
797 f(seq, event);
798 visited += 1;
799 }
800 }
801
802 // Phase 2: in-memory pending items. Here the lending pattern wins:
803 // the items are borrowed in place under the lock, with zero clones.
804 if visited < limit {
805 let inner = self.inner.lock();
806 let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
807 for (i, event) in inner.unflushed.iter().enumerate() {
808 if visited >= limit {
809 break;
810 }
811 let seq = pending_start + i as u64;
812 if seq < start_seq {
813 continue;
814 }
815 f(seq, event);
816 visited += 1;
817 }
818 }
819
820 Ok(visited)
821 }
822
823 /// Delete all on-disk segment files whose items are fully covered by
824 /// `acked_seq`.
825 ///
826 /// A segment is deleted when its `end_seq <= acked_seq`. Returns the number
827 /// of segment files removed.
828 ///
829 /// # Example
830 ///
831 /// ```
832 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
833 /// use tempfile::tempdir;
834 ///
835 /// let dir = tempdir()?;
836 /// let buf: SegmentBuffer<u64> =
837 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
838 /// for i in 0..5u64 {
839 /// buf.append(i)?;
840 /// }
841 /// buf.flush()?;
842 ///
843 /// // Consumer has processed sequence 0..=4; acknowledge them:
844 /// let removed = buf.delete_acked(4)?;
845 /// assert_eq!(removed, 1); // one segment file deleted
846 /// assert_eq!(buf.pending_count(), 0);
847 /// # Ok::<(), Box<dyn std::error::Error>>(())
848 /// ```
849 ///
850 /// # Limitation
851 ///
852 /// Acknowledgement only removes **flushed** segment files. Items still held
853 /// in the in-memory pending batch have no segment file to delete, so they
854 /// remain readable (and counted by [`SegmentBuffer::pending_count`]) until
855 /// they are flushed and acknowledged in a later call. `head_seq` is clamped
856 /// so it never advances past the pending window, keeping the backlog count
857 /// honest.
858 pub fn delete_acked(&self, acked_seq: u64) -> Result<usize> {
859 self.assert_not_reentered("delete_acked");
860 let segments = self.scan_segments()?;
861 let mut deleted = 0;
862 let mut freed_bytes: u64 = 0;
863 let mut new_head = None;
864
865 for seg in &segments {
866 if seg.end <= acked_seq {
867 let path = self.segment_path(seg.start, seg.end);
868 let file_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
869 freed_bytes += file_bytes;
870 match fs::remove_file(&path) {
871 Ok(()) => {
872 deleted += 1;
873 debug!(
874 path = path.display().to_string(),
875 seq = seg.start,
876 end_seq = seg.end,
877 bytes = file_bytes,
878 "Deleted acked segment"
879 );
880 }
881 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
882 Err(e) => return Err(e.into()),
883 }
884 } else if new_head.is_none() {
885 new_head = Some(seg.start);
886 }
887 }
888
889 // Subtract the freed bytes atomically; the lock is still needed for
890 // head_seq, but approx_disk_bytes can update independently.
891 self.approx_disk_bytes
892 .fetch_sub(freed_bytes, std::sync::atomic::Ordering::Relaxed);
893 // Deleted segment files invalidate the directory-scan cache.
894 self.invalidate_scan_cache();
895
896 {
897 let mut inner = self.inner.lock();
898 // `head_seq` tracks the oldest unacked sequence. Clamp it to the
899 // start of the in-memory pending window: items still waiting to be
900 // flushed cannot be acknowledged (there is no segment file to
901 // delete), so head_seq must not advance past them. Without this
902 // clamp, acknowledging past a buffer that still holds unflushed
903 // items would make `pending_count` under-report the real backlog.
904 let pending_start = inner.next_seq.saturating_sub(inner.unflushed.len() as u64);
905 inner.head_seq = new_head.unwrap_or(inner.next_seq).min(pending_start);
906 }
907
908 if deleted > 0 {
909 info!(
910 path = self.dir.display().to_string(),
911 deleted,
912 bytes = freed_bytes,
913 seq = acked_seq,
914 "Deleted acked segments"
915 );
916 }
917
918 Ok(deleted)
919 }
920
921 /// The highest sequence number assigned (or 0 if buffer is empty).
922 ///
923 /// # Example
924 ///
925 /// ```
926 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
927 /// use tempfile::tempdir;
928 ///
929 /// let dir = tempdir()?;
930 /// let buf: SegmentBuffer<u64> =
931 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
932 ///
933 /// assert_eq!(buf.latest_sequence(), 0);
934 /// buf.append(7)?;
935 /// assert_eq!(buf.latest_sequence(), 0);
936 /// buf.append(8)?;
937 /// assert_eq!(buf.latest_sequence(), 1);
938 /// # Ok::<(), Box<dyn std::error::Error>>(())
939 /// ```
940 #[must_use = "the sequence number is meaningless if discarded"]
941 pub fn latest_sequence(&self) -> u64 {
942 self.assert_not_reentered("latest_sequence");
943 let inner = self.inner.lock();
944 if inner.next_seq == 0 {
945 0
946 } else {
947 inner.next_seq - 1
948 }
949 }
950
951 /// Total items waiting in the buffer (on-disk + in-memory pending).
952 ///
953 /// Equivalent to `latest_sequence() - head_seq + 1` when non-empty, 0 when
954 /// empty. Decreases as [`delete_acked`](Self::delete_acked) removes files.
955 ///
956 /// # Example
957 ///
958 /// ```
959 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
960 /// use tempfile::tempdir;
961 ///
962 /// let dir = tempdir()?;
963 /// let buf: SegmentBuffer<u64> =
964 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
965 ///
966 /// assert_eq!(buf.pending_count(), 0);
967 /// buf.append(1)?;
968 /// buf.append(2)?;
969 /// assert_eq!(buf.pending_count(), 2);
970 /// buf.flush()?;
971 /// assert_eq!(buf.pending_count(), 2); // still pending until acked
972 /// buf.delete_acked(1)?;
973 /// assert_eq!(buf.pending_count(), 0);
974 /// # Ok::<(), Box<dyn std::error::Error>>(())
975 /// ```
976 #[must_use = "the backlog size is meaningless if discarded"]
977 pub fn pending_count(&self) -> u64 {
978 self.assert_not_reentered("pending_count");
979 let inner = self.inner.lock();
980 inner.next_seq.saturating_sub(inner.head_seq)
981 }
982
983 /// Standard [`len`](#method.len) alias for [`pending_count`](Self::pending_count).
984 ///
985 /// Provided so `SegmentBuffer` reads like a normal collection at the call
986 /// site (`buf.len()`, `buf.is_empty()`). Same value as `pending_count()`,
987 /// kept as `u64` because the buffer is proven beyond `usize::MAX` on
988 /// 32-bit targets (597M+ events in monitor365).
989 ///
990 /// # Example
991 ///
992 /// ```
993 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
994 /// use tempfile::tempdir;
995 ///
996 /// let dir = tempdir()?;
997 /// let buf: SegmentBuffer<u64> =
998 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
999 /// assert!(buf.is_empty());
1000 /// buf.append(7)?;
1001 /// assert_eq!(buf.len(), 1);
1002 /// assert!(!buf.is_empty());
1003 /// # Ok::<(), Box<dyn std::error::Error>>(())
1004 /// ```
1005 #[must_use = "the backlog size is meaningless if discarded"]
1006 pub fn len(&self) -> u64 {
1007 self.pending_count()
1008 }
1009
1010 /// `true` when there are no items waiting in the buffer (on-disk or
1011 /// in-memory). Equivalent to `pending_count() == 0`.
1012 ///
1013 /// # Example
1014 ///
1015 /// ```
1016 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1017 /// use tempfile::tempdir;
1018 ///
1019 /// let dir = tempdir()?;
1020 /// let buf: SegmentBuffer<u64> =
1021 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1022 /// assert!(buf.is_empty());
1023 /// # Ok::<(), Box<dyn std::error::Error>>(())
1024 /// ```
1025 #[must_use = "the emptiness flag is meaningless if discarded"]
1026 pub fn is_empty(&self) -> bool {
1027 self.pending_count() == 0
1028 }
1029
1030 /// Disk usage pressure as a value between 0.0 and 1.0.
1031 ///
1032 /// Use this to implement your own admission/backpressure policy (e.g.
1033 /// reject low-priority items above 0.90, reject standard items above 0.95).
1034 /// Returns 0.0 when `max_size_bytes == 0` (limit disabled).
1035 ///
1036 /// # Example
1037 ///
1038 /// ```
1039 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1040 /// use tempfile::tempdir;
1041 ///
1042 /// let dir = tempdir()?;
1043 /// let mut cfg = SegmentConfig::default();
1044 /// cfg.max_size_bytes = 1000; // tiny limit so pressure is observable
1045 /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), cfg)?;
1046 ///
1047 /// assert!(buf.store_pressure() < 0.1);
1048 /// # Ok::<(), Box<dyn std::error::Error>>(())
1049 /// ```
1050 #[must_use = "the pressure value is meaningless if discarded"]
1051 pub fn store_pressure(&self) -> f32 {
1052 // store_pressure only needs approx_disk_bytes + max_size_bytes —
1053 // neither requires the mutex. Read the atomic directly to avoid
1054 // contending with append/flush.
1055 if self.config.max_size_bytes == 0 {
1056 return 0.0;
1057 }
1058 let bytes = self
1059 .approx_disk_bytes
1060 .load(std::sync::atomic::Ordering::Relaxed);
1061 (bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1062 }
1063
1064 /// True when disk usage exceeds 90% of the configured limit.
1065 ///
1066 /// Convenience wrapper around `store_pressure() > 0.9`.
1067 ///
1068 /// # Example
1069 ///
1070 /// ```
1071 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1072 /// use tempfile::tempdir;
1073 ///
1074 /// let dir = tempdir()?;
1075 /// let buf: SegmentBuffer<u64> =
1076 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1077 ///
1078 /// assert!(!buf.is_overloaded());
1079 /// # Ok::<(), Box<dyn std::error::Error>>(())
1080 /// ```
1081 #[must_use = "the overload flag is meaningless if discarded"]
1082 pub fn is_overloaded(&self) -> bool {
1083 self.store_pressure() > 0.9
1084 }
1085
1086 /// Capture a consistent snapshot of buffer state under a single lock.
1087 ///
1088 /// Cheaper and more consistent than calling
1089 /// [`pending_count`](Self::pending_count),
1090 /// [`latest_sequence`](Self::latest_sequence),
1091 /// [`store_pressure`](Self::store_pressure) etc. individually (which each
1092 /// take the mutex and could observe a flush/delete between calls).
1093 ///
1094 /// # Performance
1095 ///
1096 /// Micro-benchmarked in `benches/bench_stats.rs` (run with
1097 /// `cargo bench --bench bench_stats --features encryption`):
1098 ///
1099 /// | Operation | Measured time (median, typical run) |
1100 /// |--------------------------------------------|--------------------------------------|
1101 /// | `stats()` (single lock, 7-field snapshot) | ~12 ns |
1102 /// | 3 individual accessors (`pending_count` + `latest_sequence` + `store_pressure`) | ~31 ns |
1103 ///
1104 /// So `stats()` is roughly **2.5× cheaper than 3 individual accessors**
1105 /// while also being atomic — torn reads between calls are impossible.
1106 /// Numbers are from the benchmark machine and fluctuate with hardware;
1107 /// the relative ratio is the durable claim.
1108 ///
1109 /// # Example
1110 ///
1111 /// ```
1112 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1113 /// use tempfile::tempdir;
1114 ///
1115 /// let dir = tempdir()?;
1116 /// let buf: SegmentBuffer<u64> =
1117 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1118 /// buf.append(1)?;
1119 /// buf.append(2)?;
1120 ///
1121 /// let snapshot = buf.stats();
1122 /// assert_eq!(snapshot.pending_count, 2);
1123 /// assert_eq!(snapshot.next_sequence, 2);
1124 /// assert!(snapshot.store_pressure < 0.01);
1125 /// # Ok::<(), Box<dyn std::error::Error>>(())
1126 /// ```
1127 #[must_use = "the snapshot is meaningless if discarded"]
1128 pub fn stats(&self) -> BufferStats {
1129 self.assert_not_reentered("stats");
1130 let inner = self.inner.lock();
1131 let pending_count = inner.next_seq.saturating_sub(inner.head_seq);
1132 let latest_sequence = if inner.next_seq == 0 {
1133 0
1134 } else {
1135 inner.next_seq - 1
1136 };
1137 // Load the atomic OUTSIDE the mutex's critical section logic — the
1138 // value is approximate by design, so a torn read between this load
1139 // and the inner.lock() is acceptable.
1140 let approx_disk_bytes = self
1141 .approx_disk_bytes
1142 .load(std::sync::atomic::Ordering::Relaxed);
1143 let store_pressure = if self.config.max_size_bytes == 0 {
1144 0.0
1145 } else {
1146 (approx_disk_bytes as f32 / self.config.max_size_bytes as f32).min(1.0)
1147 };
1148 BufferStats {
1149 pending_count,
1150 latest_sequence,
1151 head_sequence: inner.head_seq,
1152 next_sequence: inner.next_seq,
1153 approx_disk_bytes,
1154 max_size_bytes: self.config.max_size_bytes,
1155 store_pressure,
1156 }
1157 }
1158
1159 /// The directory this buffer reads from and writes segment files to.
1160 ///
1161 /// Useful for operators that need to inspect, archive, or quarantine the
1162 /// segment directory without parsing it out of [`Debug`](std::fmt::Debug).
1163 ///
1164 /// # Example
1165 ///
1166 /// ```
1167 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1168 /// use tempfile::tempdir;
1169 ///
1170 /// let dir = tempdir()?;
1171 /// let buf: SegmentBuffer<u64> =
1172 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1173 /// assert_eq!(buf.path(), dir.path());
1174 /// # Ok::<(), Box<dyn std::error::Error>>(())
1175 /// ```
1176 #[must_use = "the path is meaningless if discarded"]
1177 pub fn path(&self) -> &std::path::Path {
1178 &self.dir
1179 }
1180
1181 /// The [`SegmentConfig`] this buffer was opened with.
1182 ///
1183 /// Returned by reference so callers can inspect the flush policy, disk
1184 /// ceiling, compression level, and cipher presence without re-deriving
1185 /// them. The config is immutable for the lifetime of the buffer.
1186 ///
1187 /// # Example
1188 ///
1189 /// ```
1190 /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
1191 /// use tempfile::tempdir;
1192 ///
1193 /// let dir = tempdir()?;
1194 /// let config = SegmentConfig::builder()
1195 /// .flush_at_batch_size(128)
1196 /// .build();
1197 /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
1198 /// match &buf.config().flush_policy {
1199 /// FlushPolicy::Batch(n) => println!("flushing at {n} items"),
1200 /// _ => {}
1201 /// }
1202 /// # Ok::<(), Box<dyn std::error::Error>>(())
1203 /// ```
1204 #[must_use = "the config is meaningless if discarded"]
1205 pub fn config(&self) -> &SegmentConfig {
1206 &self.config
1207 }
1208
1209 /// Re-stat the segment directory and store the authoritative total as
1210 /// [`BufferStats::approx_disk_bytes`].
1211 ///
1212 /// [`BufferStats::approx_disk_bytes`] is updated incrementally on every
1213 /// flush/delete/recover, so it is accurate as long as only this buffer
1214 /// touches the directory. If an external process (backup, compaction,
1215 /// manual cleanup) adds or removes segment files, the cached value drifts.
1216 /// This method recomputes it from a directory scan.
1217 ///
1218 /// Returns the new total so callers can observe the delta without a
1219 /// second call to [`stats`](Self::stats).
1220 ///
1221 /// # Example
1222 ///
1223 /// ```
1224 /// use segment_buffer::{SegmentBuffer, SegmentConfig};
1225 /// use tempfile::tempdir;
1226 ///
1227 /// let dir = tempdir()?;
1228 /// let buf: SegmentBuffer<u64> =
1229 /// SegmentBuffer::open(dir.path(), SegmentConfig::default())?;
1230 /// buf.append(1)?;
1231 /// buf.flush()?;
1232 ///
1233 /// // Simulate an external process truncating a segment file to zero bytes.
1234 /// for entry in std::fs::read_dir(dir.path())? {
1235 /// let _ = std::fs::write(entry?.path(), b"");
1236 /// }
1237 ///
1238 /// let synced = buf.sync_disk_bytes()?;
1239 /// assert_eq!(synced, 0, "external truncation should be reflected");
1240 /// # Ok::<(), Box<dyn std::error::Error>>(())
1241 /// ```
1242 ///
1243 /// # Errors
1244 ///
1245 /// Returns [`SegmentError::Io`] if the directory cannot be read.
1246 pub fn sync_disk_bytes(&self) -> Result<u64> {
1247 self.assert_not_reentered("sync_disk_bytes");
1248 let segments = self.scan_segments()?;
1249 let total: u64 = segments
1250 .iter()
1251 .filter_map(|s| fs::metadata(self.segment_path(s.start, s.end)).ok())
1252 .map(|m| m.len())
1253 .sum();
1254 self.approx_disk_bytes
1255 .store(total, std::sync::atomic::Ordering::Relaxed);
1256 Ok(total)
1257 }
1258
1259 /// Append a batch of items under a single lock acquisition.
1260 ///
1261 /// Each item receives the next contiguous sequence number. Returns the
1262 /// last sequence number assigned (matching the contract of
1263 /// [`append`](Self::append)); the full range is
1264 /// `[last - count + 1, last]` where `count` is the number of items the
1265 /// iterator yielded.
1266 ///
1267 /// # Batch vs streaming semantics
1268 ///
1269 /// All items are accumulated under a single lock acquisition, then the
1270 /// flush policy is checked **once** at the end. This gives true atomic
1271 /// batch semantics: either the entire batch lands in the buffer or the
1272 /// error propagates. Callers who want per-item auto-flush semantics
1273 /// (flush at every `batch_size` threshold) should call
1274 /// [`append`](Self::append) in a loop instead — `append_all` is
1275 /// optimized for the "load this batch atomically" use case and avoids
1276 /// paying the lock-acquisition cost per item.
1277 ///
1278 /// # Example
1279 ///
1280 /// ```
1281 /// use segment_buffer::{SegmentBuffer, SegmentConfig, FlushPolicy};
1282 /// use tempfile::tempdir;
1283 ///
1284 /// let dir = tempdir()?;
1285 /// let config = SegmentConfig::builder()
1286 /// .flush_policy(FlushPolicy::Manual)
1287 /// .build();
1288 /// let buf: SegmentBuffer<u64> = SegmentBuffer::open(dir.path(), config)?;
1289 ///
1290 /// let last = buf.append_all([10u64, 20, 30, 40])?;
1291 /// assert_eq!(last, 3); // 0-based: items got seqs 0, 1, 2, 3
1292 /// assert_eq!(buf.pending_count(), 4);
1293 /// # Ok::<(), Box<dyn std::error::Error>>(())
1294 /// ```
1295 ///
1296 /// # Errors
1297 ///
1298 /// Returns [`SegmentError::Io`] if a flush triggered by the batch fails.
1299 pub fn append_all<I>(&self, items: I) -> Result<u64>
1300 where
1301 I: IntoIterator<Item = T>,
1302 {
1303 self.assert_not_reentered("append_all");
1304 let (should_flush, last_seq, count) = {
1305 let mut inner = self.inner.lock();
1306 let mut count = 0u64;
1307 let mut last_seq = inner.next_seq.saturating_sub(1);
1308 for item in items {
1309 inner.unflushed.push(item);
1310 inner.next_seq = inner.next_seq.wrapping_add(1);
1311 last_seq = inner.next_seq - 1;
1312 count += 1;
1313 }
1314 if count == 0 {
1315 // Empty iterator: no-op, return current last seq (or 0).
1316 return Ok(inner.next_seq.saturating_sub(1));
1317 }
1318 let should_flush = self
1319 .config
1320 .flush_policy
1321 .should_flush(inner.unflushed.len(), inner.last_flush.elapsed());
1322 (should_flush, last_seq, count)
1323 };
1324 debug_assert!(count > 0);
1325 if should_flush {
1326 self.flush()?;
1327 }
1328 Ok(last_seq)
1329 }
1330
1331 // -----------------------------------------------------------------------
1332 // Internal helpers
1333 // -----------------------------------------------------------------------
1334
1335 /// Panic with a clear message if a `for_each_from` callback is currently
1336 /// re-entering the buffer. The alternative is a silent deadlock
1337 /// (`parking_lot::Mutex` is not reentrant), so an explicit panic is
1338 /// strictly better for diagnosability.
1339 fn assert_not_reentered(&self, method: &'static str) {
1340 if self
1341 .iteration_in_progress
1342 .load(std::sync::atomic::Ordering::Relaxed)
1343 {
1344 panic!(
1345 "{method}: cannot call from within a for_each_from callback \
1346 (the buffer mutex is held; re-entry would deadlock)"
1347 );
1348 }
1349 }
1350
1351 fn recover(&self) -> Result<RecoveryReport> {
1352 let removed_tmp_files = segment::clean_tmp(&self.dir)?;
1353
1354 let segments = self.scan_segments()?;
1355
1356 // All filesystem access (stat'ing each segment for its size) happens
1357 // BEFORE the mutex is taken. The lock is held only long enough to
1358 // publish the rebuilt in-memory state, honouring the invariant that
1359 // the mutex is never held across file I/O.
1360 let total_bytes: u64 = segments
1361 .iter()
1362 .filter_map(|s| fs::metadata(self.segment_path(s.start, s.end)).ok())
1363 .map(|m| m.len())
1364 .sum();
1365
1366 let (head_seq, next_seq) = match (segments.first(), segments.last()) {
1367 (Some(first), Some(last)) => (first.start, last.end + 1),
1368 _ => (0, 0),
1369 };
1370
1371 let segment_count = segments.len();
1372 {
1373 let mut inner = self.inner.lock();
1374 inner.head_seq = head_seq;
1375 inner.next_seq = next_seq;
1376 }
1377 // Store the recovered disk-bytes total into the atomic directly.
1378 self.approx_disk_bytes
1379 .store(total_bytes, std::sync::atomic::Ordering::Relaxed);
1380 // Recovery just scanned the directory; populate the cache so the
1381 // first read_from/delete_acked after open does not re-scan.
1382 *self.scan_cache.lock() = Some(segments.clone());
1383
1384 info!(
1385 path = self.dir.display().to_string(),
1386 segments = segment_count,
1387 seq = head_seq,
1388 end_seq = next_seq,
1389 bytes = total_bytes,
1390 removed_tmp = removed_tmp_files,
1391 "Segment buffer recovered"
1392 );
1393
1394 Ok(RecoveryReport {
1395 segment_count,
1396 head_seq,
1397 next_seq,
1398 disk_bytes: total_bytes,
1399 removed_tmp_files,
1400 })
1401 }
1402
1403 fn write_segment(&self, start: u64, end: u64, events: &[T]) -> Result<u64> {
1404 let path = self.segment_path(start, end);
1405 segment::write(
1406 &self.dir,
1407 self.config.cipher.as_deref(),
1408 self.config.compression_level,
1409 SegmentRange::new(start, end),
1410 events,
1411 )
1412 .map_err(|e| e.with_path(&path))
1413 }
1414
1415 fn read_segment(&self, seg: SegmentRange) -> Result<Vec<T>> {
1416 let path = self.segment_path(seg.start, seg.end);
1417 segment::read(&self.dir, self.config.cipher.as_deref(), seg).map_err(|e| e.with_path(&path))
1418 }
1419
1420 fn scan_segments(&self) -> Result<Vec<SegmentRange>> {
1421 // Cache hit: clone under the cache lock and return.
1422 {
1423 let cache = self.scan_cache.lock();
1424 if let Some(ref segments) = *cache {
1425 return Ok(segments.clone());
1426 }
1427 }
1428 // Cache miss: scan the directory, store, return.
1429 let segments = segment::scan(&self.dir).map_err(|e| e.with_path(&self.dir))?;
1430 let mut cache = self.scan_cache.lock();
1431 *cache = Some(segments.clone());
1432 Ok(segments)
1433 }
1434
1435 /// Invalidate the scan cache. Called by every on-disk mutation
1436 /// (`flush`, `delete_acked`, `recover`).
1437 fn invalidate_scan_cache(&self) {
1438 let mut cache = self.scan_cache.lock();
1439 *cache = None;
1440 }
1441
1442 fn segment_path(&self, start: u64, end: u64) -> PathBuf {
1443 self.dir.join(segment::filename(start, end))
1444 }
1445}
1446
1447/// RAII guard that clears [`SegmentBuffer::iteration_in_progress`] on drop,
1448/// including during panic unwinding. Without this, a panicking `for_each_from`
1449/// callback would leave the flag set and permanently brick the buffer.
1450struct IterationGuard<'a>(&'a std::sync::atomic::AtomicBool);
1451
1452impl Drop for IterationGuard<'_> {
1453 fn drop(&mut self) {
1454 self.0.store(false, std::sync::atomic::Ordering::Relaxed);
1455 }
1456}
1457
1458// ---------------------------------------------------------------------------
1459// Static thread-safety assertion
1460// ---------------------------------------------------------------------------
1461
1462// `SegmentBuffer<T>` is documented as MPMC-safe via `parking_lot::Mutex`. This
1463// fails to compile if anyone ever introduces a non-`Send`/`Sync` field on
1464// `SegmentBuffer` or `BufferInner` (e.g. an `Rc`), turning the documented
1465// thread-safety guarantee into a compile-time contract instead of a comment.
1466const _: () = {
1467 const fn assert_send_sync<T: Send + Sync>() {}
1468 assert_send_sync::<SegmentBuffer<()>>();
1469};
1470
1471#[cfg(test)]
1472mod tests;
1473
1474#[cfg(test)]
1475mod property_tests;
1476
1477// Each example file is embedded as a doc-test so `cargo test --doc` gives
1478// execution coverage on top of the compilation coverage from
1479// `cargo test --examples`. The `concat!` wraps the raw file content in a
1480// code fence so rustdoc treats it as compilable+runnable Rust.
1481#[cfg(doctest)]
1482mod example_doctests {
1483 #[doc = concat!("```rust\n", include_str!("../examples/basic_usage.rs"), "\n```")]
1484 const BASIC_USAGE: () = ();
1485
1486 #[doc = concat!("```rust\n", include_str!("../examples/backpressure.rs"), "\n```")]
1487 const BACKPRESSURE: () = ();
1488
1489 #[doc = concat!("```rust\n", include_str!("../examples/crash_recovery.rs"), "\n```")]
1490 const CRASH_RECOVERY: () = ();
1491
1492 #[doc = concat!("```rust\n", include_str!("../examples/mpmc.rs"), "\n```")]
1493 const MPMC: () = ();
1494
1495 #[cfg(feature = "encryption")]
1496 #[doc = concat!("```rust\n", include_str!("../examples/encrypted.rs"), "\n```")]
1497 const ENCRYPTED: () = ();
1498}