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