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