Skip to main content

dial9_core/
buffer.rs

1use dial9_trace_format::encoder::{Encoder, RawEncoder};
2
3use crate::clock::clock_pair;
4use crate::collector::Batch;
5use crate::format::{ClockSyncEvent, SegmentMetadataEvent};
6use crate::fs::{ActiveHandle, Fs, RemoveReason};
7use crate::primitives::fs;
8use crate::rate_limit::rate_limited;
9use crate::sealed::SegmentRef;
10use std::collections::VecDeque;
11use std::io::BufWriter;
12use std::marker::PhantomData;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use metrique_timesource::time_source;
18
19mod mode_sealed {
20    pub trait Sealed {}
21}
22
23/// Marker trait for `SegmentWriter`'s backend mode. Sealed: only [`Disk`]
24/// and [`Memory`] implement it.
25pub trait BufferMode: mode_sealed::Sealed + Send + 'static {
26    /// Whether the writer mode is disk-backed.
27    const IS_DISK: bool;
28}
29
30/// Disk-backed mode (default).
31#[derive(Debug)]
32#[non_exhaustive]
33pub struct Disk;
34/// In-memory mode.
35#[derive(Debug)]
36#[non_exhaustive]
37pub struct Memory;
38
39impl mode_sealed::Sealed for Disk {}
40impl mode_sealed::Sealed for Memory {}
41impl BufferMode for Disk {
42    const IS_DISK: bool = true;
43}
44impl BufferMode for Memory {
45    const IS_DISK: bool = false;
46}
47
48/// Alias for the disk-backed writer (the default mode).
49pub type DiskBuffer = SegmentWriter<Disk>;
50/// Alias for the in-memory writer.
51pub type MemoryBuffer = SegmentWriter<Memory>;
52
53/// Segment-metadata key carrying the crates.io version of
54/// `dial9-tokio-telemetry`. Populated by default, any user-supplied entry with
55/// this key take precedence.
56const DIAL9_VERSION_KEY: &str = "dial9.dial9-tokio-telemetry.version";
57
58/// Compile-time value for `DIAL9_VERSION_KEY`.
59const DIAL9_VERSION_VALUE: &str = env!("CARGO_PKG_VERSION");
60
61/// Segment-metadata key carrying the logical CPU capacity available to the
62/// process. Populated by default when the platform can report it.
63const PROCESS_AVAILABLE_PARALLELISM_KEY: &str = "process.available_parallelism";
64
65/// Seal-time segment-metadata key, `false` when the segment is incomplete.
66///
67/// An incomplete segment is missing events that were still buffered when it was
68/// sealed, holds events belonging to its predecessor, or both.
69const SEGMENT_COMPLETE_KEY: &str = "segment.complete";
70
71/// Whether a rotation drained thread-local buffers before sealing.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum SealKind {
74    /// The flush loop drained thread-local buffers first.
75    Drained,
76    /// The segment crossed `max_file_size` mid-write.
77    Overflow,
78}
79
80impl SealKind {
81    fn is_clean(self) -> bool {
82        matches!(self, SealKind::Drained)
83    }
84}
85
86#[derive(Clone)]
87struct SegmentMetadata {
88    entries: Vec<(String, String)>,
89}
90
91impl Default for SegmentMetadata {
92    fn default() -> Self {
93        let mut entries = vec![(
94            DIAL9_VERSION_KEY.to_string(),
95            DIAL9_VERSION_VALUE.to_string(),
96        )];
97        match std::thread::available_parallelism() {
98            Ok(parallelism) => entries.push((
99                PROCESS_AVAILABLE_PARALLELISM_KEY.to_string(),
100                parallelism.get().to_string(),
101            )),
102            Err(e) => rate_limited!(Duration::from_secs(60), {
103                tracing::warn!("failed to read process available parallelism: {e}");
104            }),
105        }
106        Self { entries }
107    }
108}
109
110impl SegmentMetadata {
111    /// Build segment metadata from user-supplied entries on top of the default
112    /// `dial9.dial9-tokio-telemetry.version` key. User entries with the same key override the default.
113    fn new(user_entries: Vec<(String, String)>) -> Self {
114        let mut s = Self::default();
115        s.merge(user_entries.into_iter());
116        s
117    }
118
119    /// Merge incoming entries with existing ones. Incoming entries take priority
120    /// on key conflict; existing entries with keys not in the incoming set are preserved.
121    /// Returns `true` if the resulting entries differ from the previous state.
122    ///
123    /// The "unchanged -> no rewrite" detection (`merged == self.entries`) is a
124    /// positional `Vec` compare. A source re-emitting the same entries is only
125    /// deduped to a no-op if it emits them in a stable order across calls, so
126    /// every `Source::segment_metadata` MUST produce a deterministic order. A
127    /// nondeterministic iteration order (e.g. iterating a `HashMap`) would
128    /// reorder `merged`, fail this compare, and rewrite segment metadata on
129    /// every change-cycle.
130    fn merge(&mut self, entries: impl Iterator<Item = (String, String)>) -> bool {
131        let mut merged: Vec<(String, String)> = entries.collect();
132        for (k, v) in &self.entries {
133            if !merged.iter().any(|(mk, _)| mk == k) {
134                merged.push((k.clone(), v.clone()));
135            }
136        }
137        if merged == self.entries {
138            return false;
139        }
140        self.entries = merged;
141        true
142    }
143}
144
145/// Default rotation period: 1 minute.
146const DEFAULT_ROTATION_PERIOD: Duration = Duration::from_secs(60);
147
148/// Default maximum interval between thread-local buffer drains.
149const DEFAULT_DRAIN_INTERVAL: Duration = Duration::from_secs(30);
150
151/// Default lower bound on the adapted rotation period.
152const DEFAULT_MIN_ROTATION_PERIOD: Duration = Duration::from_secs(5);
153
154/// Percentage of `max_file_size` a segment aims for when the config leaves
155/// [`AdaptiveRotationConfig::target_bytes`] unset.
156const ROTATION_TARGET_PERCENT: u64 = 80;
157
158/// Maximum factor the rotation period may grow by in one rotation.
159const ROTATION_GROWTH_FACTOR: u32 = 2;
160
161/// Default segment filename stem for rotating writers. Segments are then
162/// `trace.0.bin`, `trace.1.bin`, and so on inside the configured directory.
163const SEGMENT_STEM: &str = "trace";
164
165const BYTES_PER_MIB: u64 = 1024 * 1024;
166
167/// Hard cap on the builder-derived per-file size, regardless of the total
168/// disk budget. Time-based rotation should fire first under normal load;
169/// this cap keeps individual segments small enough to remain manageable.
170const MAX_FILE_SIZE_CAP: u64 = 100 * BYTES_PER_MIB;
171
172/// Default per-file rotation threshold derived from the total disk budget.
173/// Picks a quarter of the budget so a single segment never dominates
174/// retention, capped at 100 MiB.
175fn derive_max_file_size(max_total_size: u64) -> u64 {
176    (max_total_size / 4).min(MAX_FILE_SIZE_CAP)
177}
178
179/// Sets the rotation period from the observed byte rate, so segments land near
180/// a target size and stay under `max_file_size`. Opt in with
181/// `adaptive_rotation` on [`DiskBuffer::builder`] or [`MemoryBuffer::builder`].
182///
183/// The period starts at `min_period` and grows toward `max_period` as the
184/// observed rate allows.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct AdaptiveRotationConfig {
187    min_period: Duration,
188    max_period: Option<Duration>,
189    target_bytes: Option<u64>,
190}
191
192impl Default for AdaptiveRotationConfig {
193    fn default() -> Self {
194        Self {
195            min_period: DEFAULT_MIN_ROTATION_PERIOD,
196            max_period: None,
197            target_bytes: None,
198        }
199    }
200}
201
202impl AdaptiveRotationConfig {
203    /// Shortest period to adapt to. Every rotation drains all registered
204    /// thread-local buffers, so this bounds that cost. Defaults to 5s.
205    pub fn min_period(mut self, period: Duration) -> Self {
206        self.min_period = period;
207        self
208    }
209
210    /// Longest period to adapt to. Defaults to the writer's `rotation_period`.
211    pub fn max_period(mut self, period: Duration) -> Self {
212        self.max_period = Some(period);
213        self
214    }
215
216    /// Bytes each segment aims for. Defaults to 80% of `max_file_size`, leaving
217    /// room for a rate that rises within a segment.
218    pub fn target_bytes(mut self, bytes: u64) -> Self {
219        self.target_bytes = Some(bytes);
220        self
221    }
222}
223
224/// A writer that rotates trace segments to bound resource usage and time.
225/// Generic over backend: use [`DiskBuffer`] (files) or [`MemoryBuffer`].
226///
227/// Rotation triggers when *either* condition is met:
228/// - `rotation_period`: this much monotonic time has elapsed since the writer
229///   (or the previous rotation) started (default: 1 minute)
230/// - `max_file_size`: the active segment exceeds this many bytes
231///
232/// Timed rotation runs through the flush loop, which drains thread-local buffers
233/// before the segment is sealed, so each segment holds a clean, non-overlapping
234/// time window. A segment that crosses `max_file_size` is sealed immediately,
235/// without a drain. It is missing events that were still buffered, and the
236/// segment after it carries them.
237///
238/// [`AdaptiveRotationConfig`] keeps segments off that second path: the period
239/// tracks the observed byte rate so segments land near a target size, and the
240/// drain interval follows it, keeping buffered events from aging past the
241/// segment they belong to. Off by default.
242///
243/// When using [`DiskBuffer::builder`] without specifying `max_file_size`, it
244/// defaults to `min(100 MiB, max_total_size / 4)` on disk.
245///
246/// `max_total_size` is the retention budget across closed segments. The
247/// oldest segments are dropped once the total exceeds this budget.
248///
249/// The trace lives in a directory (`dir`); disk segments are named
250/// `{dir}/{stem}.0.bin`, `{dir}/{stem}.1.bin`, etc., each a self-contained
251/// trace with its own header. Rotating writers use the stem `trace`;
252/// [`single_file`](Self::single_file) takes the stem from the given file name.
253pub struct SegmentWriter<Mode: BufferMode = Disk> {
254    /// Directory the segments live in.
255    dir: PathBuf,
256    /// Segment filename stem, e.g. `trace` for `trace.0.bin`.
257    stem: String,
258    max_file_size: u64,
259    max_total_size: u64,
260    /// How often to rotate based on monotonic time. `Duration::MAX` disables
261    /// time-based rotation (used by `single_file()`).
262    rotation_period: Duration,
263    /// The next monotonic instant at which time-based rotation should fire,
264    /// or `None` if time-based rotation is disabled.
265    next_rotation_time: Option<Instant>,
266    /// Tracks (seg_ref, size) of closed segments oldest-first for disk eviction.
267    /// Always empty in memory mode (eviction handled by the memory backend).
268    closed_files: VecDeque<(SegmentRef, u64)>,
269    /// Path of the currently active (being-written) segment.
270    /// Used as a HashMap key in memory mode; a real path in disk mode.
271    active_path: PathBuf,
272    state: WriterState,
273    next_index: u32,
274    /// Metadata written at the start of each segment. Updated by the flush
275    /// thread to include runtime names alongside any user-provided entries.
276    segment_metadata: SegmentMetadata,
277    /// Events silently dropped because the writer was finished/stopped.
278    dropped_events: usize,
279    /// How segments have been sealed so far, reported on the flush metrics.
280    seals: crate::metrics::SealCounts,
281    /// Whether any real (non-metadata) events have been written to the current segment.
282    /// Reset on rotation; used by `finalize()` to avoid sealing empty segments.
283    has_real_events: bool,
284    /// Byte-rate adaptation settings. `None` holds the period fixed at
285    /// `rotation_period`.
286    adaptive_rotation: Option<AdaptiveRotationConfig>,
287    /// The period in force. Equal to `rotation_period` unless `adaptive_rotation` is set,
288    /// in which case it starts at the configured minimum and grows from there.
289    current_period: Duration,
290    /// When the active segment was opened, for the byte-rate estimate.
291    segment_started_at: Instant,
292    /// How the preceding segment was sealed, recorded into this one.
293    prior_seal_kind: SealKind,
294    /// Next monotonic instant at which `should_drain()` returns true.
295    next_drain_time: Instant,
296    /// Unified filesystem/channel abstraction.
297    fs: Arc<Fs>,
298    boot_id: Option<String>,
299    _namespace_lock: Option<std::fs::File>,
300    _mode: PhantomData<Mode>,
301}
302
303impl<M: BufferMode> std::fmt::Debug for SegmentWriter<M> {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        f.debug_struct("SegmentWriter")
306            .field("dir", &self.dir)
307            .field("stem", &self.stem)
308            .field("max_file_size", &self.max_file_size)
309            .field("max_total_size", &self.max_total_size)
310            .finish_non_exhaustive()
311    }
312}
313
314// the write side is obviously larger than the `Finished` size so clippy warns on this
315// but we don't want to force going through a pointer every time we want to write.
316#[allow(clippy::large_enum_variant)]
317enum WriterState {
318    /// Writer is open and events can be written
319    Active {
320        writer: RawEncoder<BufWriter<ActiveHandle>>,
321        need_metadata: bool,
322    },
323
324    /// Writer has been finalized or stopped — no encoder, no fd, no writes.
325    Finished,
326}
327
328#[bon::bon]
329impl SegmentWriter<Disk> {
330    /// Create a `DiskBufferBuilder` for advanced configuration.
331    ///
332    /// When `max_file_size` is omitted, it defaults to
333    /// `min(100 MiB, max_total_size / 4)`.
334    #[builder(builder_type = DiskBufferBuilder, finish_fn = build)]
335    pub fn builder(
336        base_path: impl Into<PathBuf>,
337        /// Per-file rotation threshold in bytes. Defaults to
338        /// `min(100 MiB, max_total_size / 4)` when not set.
339        max_file_size: Option<u64>,
340        max_total_size: u64,
341        /// How often to rotate, measured in monotonic time since the writer
342        /// (or the previous rotation) started. Defaults to 60 seconds.
343        /// `Duration::MAX` disables time-based rotation.
344        rotation_period: Option<Duration>,
345        /// Set the rotation period from the observed byte rate. Off by
346        /// default; see [`AdaptiveRotationConfig`].
347        adaptive_rotation: Option<AdaptiveRotationConfig>,
348        segment_metadata: Option<Vec<(String, String)>>,
349    ) -> std::io::Result<Self> {
350        Self::create(
351            base_path,
352            max_file_size.unwrap_or_else(|| derive_max_file_size(max_total_size)),
353            max_total_size,
354            rotation_period.unwrap_or(DEFAULT_ROTATION_PERIOD),
355            adaptive_rotation,
356            segment_metadata
357                .map(SegmentMetadata::new)
358                .unwrap_or_default(),
359        )
360    }
361
362    fn create(
363        base_path: impl Into<PathBuf>,
364        max_file_size: u64,
365        max_total_size: u64,
366        rotation_period: Duration,
367        adaptive_rotation: Option<AdaptiveRotationConfig>,
368        segment_metadata: SegmentMetadata,
369    ) -> std::io::Result<Self> {
370        if rotation_period == Duration::from_secs(0) {
371            return Err(std::io::Error::other("Rotation period must not be zero"));
372        }
373        // The trace is a directory of `{stem}.{index}.bin` segments.
374        let dir = base_path.into();
375        if !dir.as_os_str().is_empty() {
376            fs::create_dir_all(&dir)?;
377        }
378        let stem = SEGMENT_STEM.to_string();
379        let fs = Fs::new_disk(&dir, stem.as_str());
380        let discovered = fs.discover_existing()?;
381        let first_index = discovered.next_active_index;
382        let next_index = first_index
383            .checked_add(1)
384            .ok_or_else(|| std::io::Error::other("trace segment index overflow"))?;
385        let first_path = Self::active_path(&dir, &stem, first_index);
386        let handle = fs.create_segment(&first_path)?;
387        let state = Self::prepare_segment(BufWriter::new(handle))?;
388        let now = time_source().instant().as_std();
389        // Adaptation starts short and grows, so the first deadline and the
390        // drain that gates it both follow the adapted period, not the ceiling.
391        let first_period = adaptive_rotation.map_or(rotation_period, |c| c.min_period);
392        let drain_interval = first_period.min(DEFAULT_DRAIN_INTERVAL);
393
394        let mut writer = Self {
395            dir,
396            stem,
397            max_file_size,
398            max_total_size,
399            rotation_period,
400            next_rotation_time: Self::next_rotation_from(now, first_period),
401            closed_files: discovered.closed_files,
402            active_path: first_path,
403            state,
404            next_index,
405            segment_metadata,
406            dropped_events: 0,
407            seals: Default::default(),
408            has_real_events: false,
409            adaptive_rotation,
410            current_period: first_period,
411            segment_started_at: now,
412            prior_seal_kind: SealKind::Drained,
413            next_drain_time: now + drain_interval,
414            fs,
415            boot_id: None,
416            _namespace_lock: None,
417            _mode: PhantomData,
418        };
419        // Enforce the budget immediately so artifacts from prior writer
420        // lifetimes don't push us over the cap before we even rotate once.
421        writer.evict_oldest()?;
422        Ok(writer)
423    }
424
425    /// Set the namespace for this writer:
426    /// - `boot_id`: The boot id for the namespace.
427    /// - `lock`: The lock file for the namespace.
428    pub fn set_namespace(&mut self, boot_id: String, lock: std::fs::File) {
429        self.boot_id = Some(boot_id);
430        self._namespace_lock = Some(lock);
431    }
432
433    /// Create a writer that writes to a single file with no rotation or eviction.
434    /// The segment is written to `{stem}.0.bin.active` while active, then sealed
435    /// to `{stem}.0.bin` on `finalize`. The background worker will symbolize
436    /// and gzip it to `{stem}.0.bin.gz`.
437    ///
438    /// Note: This API does not allow the ability to provide custom segment metadata.
439    /// Time-based rotation is disabled.
440    pub fn single_file(path: impl Into<PathBuf>) -> std::io::Result<Self> {
441        let path = path.into();
442        // Unlike rotating writers, `single_file` takes both the directory and
443        // the stem from the caller's file path.
444        let dir = path
445            .parent()
446            .filter(|p| !p.as_os_str().is_empty())
447            .unwrap_or(Path::new("."))
448            .to_path_buf();
449        let stem = path
450            .file_stem()
451            .and_then(|s| s.to_str())
452            .unwrap_or(SEGMENT_STEM)
453            .to_string();
454        let fs = Fs::new_disk(&dir, stem.as_str());
455        let active_path = Self::active_path(&dir, &stem, 0);
456        let handle = fs.create_segment(&active_path)?;
457        let state = Self::prepare_segment(BufWriter::new(handle))?;
458        let now = time_source().instant().as_std();
459
460        Ok(Self {
461            dir,
462            stem,
463            max_file_size: u64::MAX,
464            max_total_size: u64::MAX,
465            rotation_period: Duration::MAX,
466            next_rotation_time: None,
467            closed_files: VecDeque::new(),
468            active_path,
469            state,
470            next_index: 1,
471            segment_metadata: SegmentMetadata::default(),
472            dropped_events: 0,
473            seals: Default::default(),
474            has_real_events: false,
475            adaptive_rotation: None,
476            current_period: Duration::MAX,
477            segment_started_at: now,
478            prior_seal_kind: SealKind::Drained,
479            next_drain_time: now + DEFAULT_DRAIN_INTERVAL,
480            fs,
481            boot_id: None,
482            _namespace_lock: None,
483            _mode: PhantomData,
484        })
485    }
486}
487
488/// Default segment size when no explicit segment size is provided.
489/// Always at least 8 slots of burst headroom in the ring.
490fn pick_segment_size(max_total_size: u64) -> u64 {
491    const MIN_SLOTS: u64 = 8;
492    (max_total_size / MIN_SLOTS).max(1)
493}
494
495#[bon::bon]
496impl SegmentWriter<Memory> {
497    /// Create an in-memory writer with a total byte budget. Segments live in process heap
498    /// instead of files. Auto-picks a reasonable segment size,
499    /// use [`builder`](Self::builder) for explicit control.
500    ///
501    /// Same rotation semantics as the disk path. Errors when
502    /// `max_total_size == 0`.
503    pub fn new(max_total_size: u64) -> std::io::Result<Self> {
504        Self::create_in_memory(
505            max_total_size,
506            pick_segment_size(max_total_size),
507            DEFAULT_ROTATION_PERIOD,
508            None,
509            SegmentMetadata::default(),
510        )
511    }
512
513    /// Builder for in-memory writer configuration.
514    #[builder(builder_type = MemoryBufferBuilder, finish_fn = build)]
515    pub fn builder(
516        max_total_size: u64,
517        /// Override the default segment size.
518        max_segment_size: Option<u64>,
519        /// Wall-clock rotation period.
520        rotation_period: Option<Duration>,
521        /// Set the rotation period from the observed byte rate. Off by
522        /// default.
523        adaptive_rotation: Option<AdaptiveRotationConfig>,
524        segment_metadata: Option<Vec<(String, String)>>,
525    ) -> std::io::Result<Self> {
526        let seg_size = max_segment_size.unwrap_or_else(|| pick_segment_size(max_total_size));
527        Self::create_in_memory(
528            max_total_size,
529            seg_size,
530            rotation_period.unwrap_or(DEFAULT_ROTATION_PERIOD),
531            adaptive_rotation,
532            segment_metadata
533                .map(SegmentMetadata::new)
534                .unwrap_or_default(),
535        )
536    }
537
538    fn create_in_memory(
539        max_total_size: u64,
540        max_segment_size: u64,
541        rotation_period: Duration,
542        adaptive_rotation: Option<AdaptiveRotationConfig>,
543        segment_metadata: SegmentMetadata,
544    ) -> std::io::Result<Self> {
545        if max_total_size == 0 {
546            return Err(std::io::Error::new(
547                std::io::ErrorKind::InvalidInput,
548                "max_total_size must be > 0",
549            ));
550        }
551        if max_segment_size == 0 {
552            return Err(std::io::Error::new(
553                std::io::ErrorKind::InvalidInput,
554                "max_segment_size must be > 0",
555            ));
556        }
557        if rotation_period == Duration::from_secs(0) {
558            return Err(std::io::Error::other("Rotation period must not be zero"));
559        }
560        // The active buffer and the worker's in-flight segment live outside the ring, so the ring needs
561        // room for at least one sealed segment on top of that reserve.
562        let min_total = (crate::fs::PIPELINE_RESERVE_SEGMENTS + 1).saturating_mul(max_segment_size);
563        if max_total_size < min_total {
564            return Err(std::io::Error::new(
565                std::io::ErrorKind::InvalidInput,
566                format!(
567                    "max_total_size ({max_total_size}) must be >= {min_total} \
568                     ({} × max_segment_size: 1 active + 1 in-flight + 1 ring slot)",
569                    crate::fs::PIPELINE_RESERVE_SEGMENTS + 1
570                ),
571            ));
572        }
573        let fs = Fs::new_in_memory(max_total_size, max_segment_size)?;
574        // The memory backend ignores paths, but `active_path` still needs a
575        // dir/stem to build its HashMap keys.
576        let dir = PathBuf::from("mem");
577        let stem = SEGMENT_STEM.to_string();
578        let active_path = Self::active_path(&dir, &stem, 0);
579        let handle = fs.create_segment(&active_path)?;
580        let state = Self::prepare_segment(BufWriter::new(handle))?;
581        let now = time_source().instant().as_std();
582        // Drain at least as often as we rotate.
583        // Adaptation starts short and grows, so the first deadline and the
584        // drain that gates it both follow the adapted period, not the ceiling.
585        let first_period = adaptive_rotation.map_or(rotation_period, |c| c.min_period);
586        let drain_interval = first_period.min(DEFAULT_DRAIN_INTERVAL);
587
588        Ok(Self {
589            dir,
590            stem,
591            max_file_size: max_segment_size,
592            max_total_size,
593            rotation_period,
594            next_rotation_time: Self::next_rotation_from(now, first_period),
595            closed_files: VecDeque::new(),
596            active_path,
597            state,
598            next_index: 1,
599            segment_metadata,
600            dropped_events: 0,
601            seals: Default::default(),
602            has_real_events: false,
603            adaptive_rotation,
604            current_period: first_period,
605            segment_started_at: now,
606            prior_seal_kind: SealKind::Drained,
607            next_drain_time: now + drain_interval,
608            fs,
609            boot_id: None,
610            _namespace_lock: None,
611            _mode: PhantomData,
612        })
613    }
614}
615
616impl<M: BufferMode> SegmentWriter<M> {
617    /// Per-process boot identifier, if namespace isolation is active. This is
618    /// the name of the [`trace_dir`](Self::trace_dir) subdirectory.
619    pub fn boot_id(&self) -> Option<&str> {
620        self.boot_id.as_deref()
621    }
622
623    /// Directory this writer's trace segments live in. When namespace
624    /// isolation is active this is the per-process `{configured_dir}/{boot_id}/`
625    /// subdirectory; otherwise it is the configured directory directly. Use
626    /// this to locate the segment files on disk.
627    pub fn trace_dir(&self) -> &Path {
628        &self.dir
629    }
630
631    /// Segment filename stem, e.g. `trace` for `trace.0.bin`.
632    pub fn trace_stem(&self) -> &str {
633        &self.stem
634    }
635
636    /// The path of the currently active (being-written) segment file.
637    pub fn current_active_path(&self) -> &Path {
638        &self.active_path
639    }
640
641    /// Create an encoder, write the file header, segment metadata, and a
642    /// clock-sync anchor, then convert to a [`RawEncoder`] for the
643    /// remainder of the file's lifetime.
644    fn prepare_segment(writer: BufWriter<ActiveHandle>) -> std::io::Result<WriterState> {
645        let mut encoder = Encoder::new_to(writer)?;
646        let (mono, real) = clock_pair();
647        encoder.write(&ClockSyncEvent {
648            timestamp_ns: mono,
649            realtime_ns: real,
650        })?;
651        Ok(WriterState::Active {
652            writer: encoder.into_raw_encoder(),
653            need_metadata: true,
654        })
655    }
656
657    fn write_metadata_if_needed(&mut self) -> std::io::Result<()> {
658        match &mut self.state {
659            WriterState::Active {
660                writer,
661                need_metadata,
662            } => {
663                if *need_metadata {
664                    Self::write_segment_metadata(writer, &self.segment_metadata.entries)?;
665                }
666                *need_metadata = false;
667                Ok(())
668            }
669            WriterState::Finished => Ok(()),
670        }
671    }
672
673    /// Record whether this segment holds its own time window.
674    ///
675    /// Repeats the full entry map, so readers that replace their metadata on
676    /// each `SegmentMetadataEvent` keep the entries written at segment start.
677    /// A failed write is logged and the segment is sealed without the key.
678    fn write_seal_metadata(&mut self, kind: SealKind) {
679        let complete = kind.is_clean() && self.prior_seal_kind.is_clean();
680        let mut entries = self.segment_metadata.entries.clone();
681        entries.push((SEGMENT_COMPLETE_KEY.to_string(), complete.to_string()));
682        let WriterState::Active { writer, .. } = &mut self.state else {
683            return;
684        };
685        if let Err(e) = Self::write_segment_metadata(writer, &entries) {
686            rate_limited!(Duration::from_secs(60), {
687                tracing::warn!("failed to write seal metadata: {e}");
688            });
689        }
690    }
691
692    /// Write a `SegmentMetadataEvent` and a fresh `ClockSyncEvent` into
693    /// the current active segment.
694    fn write_segment_metadata(
695        writer: &mut RawEncoder<BufWriter<ActiveHandle>>,
696        entries: &[(String, String)],
697    ) -> std::io::Result<()> {
698        let mut enc = Encoder::new();
699        let entries = entries.to_vec();
700        let (mono, real) = clock_pair();
701        enc.write(&SegmentMetadataEvent {
702            timestamp_ns: mono,
703            entries,
704        })?;
705        enc.write(&ClockSyncEvent {
706            timestamp_ns: mono,
707            realtime_ns: real,
708        })?;
709        writer.write_raw(&enc.finish())?;
710        Ok(())
711    }
712
713    /// Path for a segment that is actively being written.
714    fn active_path(dir: &Path, stem: &str, index: u32) -> PathBuf {
715        dir.join(format!("{stem}.{index}.bin.active"))
716    }
717
718    /// Compute the next rotation deadline as `now + period`, or `None` when
719    /// `period == Duration::MAX` (time-based rotation disabled).
720    fn next_rotation_from(now: Instant, period: Duration) -> Option<Instant> {
721        (period != Duration::MAX).then(|| now + period)
722    }
723
724    fn rotate(&mut self, kind: SealKind) -> std::io::Result<()> {
725        if matches!(self.state, WriterState::Finished) {
726            return Ok(());
727        }
728        // Stamp before advancing: write_seal_metadata reports the previous
729        // segment's seal, so it has to run before `prior_seal_kind` moves on.
730        self.write_seal_metadata(kind);
731        self.prior_seal_kind = kind;
732        self.count_seal(kind);
733
734        // Advance timers up front. If anything below fails the flush loop must
735        // NOT see should_drain() return true on the next 5ms tick — otherwise
736        // it busy-spins re-attempting the same failing rotate.
737        let now = time_source().instant().as_std();
738        let filled = self.active_bytes();
739        self.adapt_rotation_period(
740            filled,
741            now.saturating_duration_since(self.segment_started_at),
742        );
743        self.segment_started_at = now;
744        self.next_rotation_time = Self::next_rotation_from(now, self.current_period);
745        self.next_drain_time = now + self.drain_interval();
746
747        // Take ownership of the encoder (state is Finished until new segment opens).
748        let WriterState::Active {
749            writer: mut raw, ..
750        } = std::mem::replace(&mut self.state, WriterState::Finished)
751        else {
752            return Ok(());
753        };
754
755        // Best-effort flush. If the underlying file is gone the buffered bytes
756        // are already lost; proceed to rotate rather than erroring.
757        let _ = raw.flush();
758        let closed_size = raw.bytes_written();
759        let current_index = self.next_index - 1;
760
761        // Extract the ActiveHandle for sealing.
762        let bw: BufWriter<ActiveHandle> = raw.into_inner();
763        let handle: ActiveHandle = bw
764            .into_inner()
765            .unwrap_or_else(|e| e.into_inner().into_parts().0);
766
767        // Seal the current segment. If `.active` was removed externally
768        // (disk only: operator, log rotation, container teardown) abandon the
769        // segment and start a fresh one.
770        match self.fs.seal(handle, &self.active_path, current_index) {
771            Ok(seg_ref) => {
772                if M::IS_DISK {
773                    self.closed_files.push_back((seg_ref, closed_size));
774                }
775            }
776            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
777                rate_limited!(Duration::from_secs(60), {
778                    tracing::warn!(
779                        "active trace file {} disappeared before sealing; \
780                         abandoning segment and starting a fresh one",
781                        self.active_path.display()
782                    );
783                });
784            }
785            Err(e) => {
786                // state is already Finished from mem::replace above
787                return Err(e);
788            }
789        }
790
791        let new_path = Self::active_path(&self.dir, &self.stem, self.next_index);
792        self.next_index += 1;
793
794        // Open the new active segment. The backend self-heals if a disk
795        // parent directory was removed underneath us, any other failure
796        // leaves state = Finished so the writer stops cleanly rather than
797        // retrying every drain cycle.
798        let handle: ActiveHandle = self.fs.create_segment(&new_path)?;
799
800        self.state = match Self::prepare_segment(BufWriter::new(handle)) {
801            Ok(s) => s,
802            Err(e) => {
803                let _ = self.fs.remove_active(&new_path);
804                return Err(e);
805            }
806        };
807        self.active_path = new_path;
808        self.has_real_events = false;
809
810        tracing::debug!(
811            segment_index = self.next_index - 1,
812            "rotated to new trace segment"
813        );
814        self.evict_oldest()?;
815        Ok(())
816    }
817
818    fn active_bytes(&self) -> u64 {
819        match &self.state {
820            WriterState::Active { writer, .. } => writer.bytes_written(),
821            WriterState::Finished => 0,
822        }
823    }
824
825    /// What the period estimate aims each segment at, kept under
826    /// `max_file_size` so an estimate that runs a little long still rotates on
827    /// the clock.
828    fn target_segment_bytes(&self) -> u64 {
829        if let Some(bytes) = self.adaptive_rotation.and_then(|c| c.target_bytes) {
830            return bytes;
831        }
832        // Divide first: max_file_size is u64::MAX when rotation is disabled.
833        self.max_file_size / 100 * ROTATION_TARGET_PERCENT
834    }
835
836    /// How often thread-local buffers are drained.
837    fn drain_interval(&self) -> Duration {
838        self.current_period.min(DEFAULT_DRAIN_INTERVAL)
839    }
840
841    /// Re-estimate the rotation period from the byte rate of the segment just
842    /// closed, so the next one reaches the byte target as the period expires.
843    ///
844    /// The new period is capped at [`ROTATION_GROWTH_FACTOR`] times the current
845    /// one, so reaching a much longer period takes several rotations. A shorter
846    /// period applies immediately.
847    fn adapt_rotation_period(&mut self, closed_bytes: u64, elapsed: Duration) {
848        let Some(config) = self.adaptive_rotation else {
849            return;
850        };
851        let secs = elapsed.as_secs_f64();
852        if closed_bytes == 0 || secs <= 0.0 {
853            return;
854        }
855        let rate = closed_bytes as f64 / secs;
856        let Ok(estimate) = Duration::try_from_secs_f64(self.target_segment_bytes() as f64 / rate)
857        else {
858            return;
859        };
860        let ceiling = config.max_period.unwrap_or(self.rotation_period);
861        let floor = config.min_period.min(ceiling);
862        let capped = estimate.min(self.current_period * ROTATION_GROWTH_FACTOR);
863        self.current_period = capped.clamp(floor, ceiling);
864    }
865
866    /// Total size across all closed + active segments (disk mode only).
867    /// Always returns 0 in memory mode, eviction is handled by the memory backend.
868    fn total_size(&self) -> u64 {
869        if !M::IS_DISK {
870            return 0;
871        }
872        let closed: u64 = self.closed_files.iter().map(|(_, s)| s).sum();
873        closed + self.active_bytes()
874    }
875
876    fn evict_oldest(&mut self) -> std::io::Result<()> {
877        if !M::IS_DISK {
878            return Ok(());
879        }
880        // Always keep at least the current file.
881        while self.total_size() > self.max_total_size && !self.closed_files.is_empty() {
882            if let Some((seg_ref, _size)) = self.closed_files.pop_front() {
883                self.fs.remove_sealed(&seg_ref, RemoveReason::Eviction);
884            }
885        }
886        // If even the current file alone exceeds total budget, stop writing.
887        if self.total_size() > self.max_total_size {
888            self.state = WriterState::Finished;
889        }
890        Ok(())
891    }
892
893    /// Rotate if the current file exceeds max_file_size.
894    /// Called after writing a complete logical unit (def + event).
895    fn maybe_rotate(&mut self) -> std::io::Result<()> {
896        let WriterState::Active { writer: raw, .. } = &self.state else {
897            return Ok(());
898        };
899        if raw.bytes_written() > self.max_file_size {
900            self.rotate(SealKind::Overflow)?;
901        }
902        Ok(())
903    }
904}
905
906impl<M: BufferMode> SegmentWriter<M> {
907    /// The filesystem backend, handed to the worker so it can drain sealed
908    /// segments. Only needed when the pipeline worker is compiled in.
909    #[cfg(feature = "pipeline")]
910    pub(crate) fn fs_handle(&self) -> Option<Arc<Fs>> {
911        Some(Arc::clone(&self.fs))
912    }
913
914    /// Flush buffered data to the underlying storage.
915    pub fn flush(&mut self) -> std::io::Result<()> {
916        if let WriterState::Active { writer: raw, .. } = &mut self.state {
917            raw.flush()?;
918        }
919        Ok(())
920    }
921
922    #[cfg(test)]
923    pub(crate) fn segment_metadata(&self) -> &[(String, String)] {
924        &self.segment_metadata.entries
925    }
926
927    /// Merge the segment metadata entries written into the next rotated segment.
928    ///
929    /// Accepts any iterator so callers can drain a reused buffer (retaining its
930    /// capacity) instead of handing over an owned `Vec`. A `Vec` still works.
931    pub fn update_segment_metadata(&mut self, entries: impl IntoIterator<Item = (String, String)>) {
932        if self.segment_metadata.merge(entries.into_iter()) {
933            match &mut self.state {
934                WriterState::Active { need_metadata, .. } => *need_metadata = true,
935                WriterState::Finished => {}
936            }
937        }
938    }
939
940    pub(crate) fn write_current_segment_metadata(&mut self) -> std::io::Result<()> {
941        self.write_metadata_if_needed()
942    }
943
944    fn count_seal(&mut self, kind: SealKind) {
945        let counter = if kind.is_clean() {
946            &mut self.seals.segments_sealed_clean
947        } else {
948            &mut self.seals.segments_sealed_undrained
949        };
950        *counter += 1;
951    }
952
953    pub(crate) fn seal_counts(&self) -> crate::metrics::SealCounts {
954        self.seals
955    }
956
957    pub(crate) fn should_drain(&self) -> bool {
958        self.has_real_events && time_source().instant().as_std() >= self.next_drain_time
959    }
960
961    pub(crate) fn drained(&mut self) -> std::io::Result<bool> {
962        if !self.has_real_events {
963            return Ok(false);
964        }
965        let now = time_source().instant().as_std();
966        if self
967            .next_rotation_time
968            .is_some_and(|deadline| now >= deadline)
969        {
970            self.rotate(SealKind::Drained)?;
971            return Ok(true);
972        }
973        // Periodic drain without rotation; advance the drain timer.
974        self.next_drain_time = now + self.drain_interval();
975        Ok(false)
976    }
977
978    /// Finalize the writer: flush, seal the active segment, and prevent further
979    /// writes. Terminal — the writer is inert afterward.
980    pub fn finalize(&mut self) -> std::io::Result<()> {
981        if matches!(self.state, WriterState::Finished) {
982            rate_limited!(Duration::from_secs(60), {
983                tracing::warn!("writer is already closed.");
984            });
985            self.fs.mark_writer_done();
986            return Ok(());
987        }
988        self.write_seal_metadata(SealKind::Drained);
989        self.count_seal(SealKind::Drained);
990        // Best-effort flush: if the file is gone the bytes are already lost.
991        let _ = self.flush();
992
993        // Take ownership of the encoder (state -> Finished).
994        let WriterState::Active { writer: raw, .. } =
995            std::mem::replace(&mut self.state, WriterState::Finished)
996        else {
997            self.fs.mark_writer_done();
998            return Ok(());
999        };
1000
1001        let bytes_written = raw.bytes_written();
1002        let bw: BufWriter<ActiveHandle> = raw.into_inner();
1003        let handle: ActiveHandle = bw
1004            .into_inner()
1005            .unwrap_or_else(|e| e.into_inner().into_parts().0);
1006
1007        let current_index = self.next_index - 1;
1008
1009        if self.has_real_events {
1010            match self.fs.seal(handle, &self.active_path, current_index) {
1011                Ok(seg_ref) => {
1012                    if M::IS_DISK {
1013                        self.closed_files.push_back((seg_ref, bytes_written));
1014                    }
1015                }
1016                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1017                    rate_limited!(Duration::from_secs(60), {
1018                        tracing::warn!(
1019                            "active trace file {} disappeared before finalize; \
1020                             dropping segment",
1021                            self.active_path.display()
1022                        );
1023                    });
1024                }
1025                Err(e) => {
1026                    self.fs.mark_writer_done();
1027                    return Err(e);
1028                }
1029            }
1030        } else {
1031            // No real events — just header + metadata. Remove instead of
1032            // sealing so the background worker doesn't upload an empty segment.
1033            tracing::debug!(
1034                "removing empty final segment {}",
1035                self.active_path.display()
1036            );
1037            if let Err(e) = self.fs.remove_active(&self.active_path)
1038                && e.kind() != std::io::ErrorKind::NotFound
1039            {
1040                self.fs.mark_writer_done();
1041                return Err(e);
1042            }
1043        }
1044
1045        // Final sealed segment must count toward the eviction budget too,
1046        // otherwise finalize can leave the directory over `max_total_size`.
1047        // No-ops on memory mode (`!M::IS_DISK`).
1048        if let Err(e) = self.evict_oldest() {
1049            self.fs.mark_writer_done();
1050            return Err(e);
1051        }
1052        self.fs.mark_writer_done();
1053        Ok(())
1054    }
1055
1056    // `pub(crate)` in production: the flush loop drives it. `pub` under
1057    // `test-util` so sibling-crate tests/benches can write pre-encoded batches.
1058    crate::test_util_pub! {
1059    /// Transcode an encoded batch into the active segment.
1060    fn write_encoded_batch(&mut self, batch: &Batch) -> std::io::Result<()> {
1061        self.write_metadata_if_needed()?;
1062        let WriterState::Active { writer: raw, .. } = &mut self.state else {
1063            self.dropped_events += batch.event_count() as usize;
1064            return Ok(());
1065        };
1066        if batch.event_count() > 0 {
1067            // Note: we do NOT advance next_rotation_time or next_drain_time
1068            // when the first event arrives in an empty segment, even if the
1069            // timers are stale. The drain state machine (Idle → EpochBumped →
1070            // drain) takes 3 flush cycles (~15ms) to complete, so by the time
1071            // drained() is called there will be multiple batches in the segment,
1072            // not a single event. Advancing the timers here would skip rotation
1073            // windows and produce fewer segments than expected.
1074            // Raw-copy the thread-local batch. Each batch is self-contained
1075            // (starts with its own header), so the next batch's header acts as
1076            // the reset frame for decoders.
1077            raw.write_raw(batch.encoded_bytes())?;
1078            self.has_real_events = true;
1079            self.maybe_rotate()?;
1080        }
1081        Ok(())
1082    }
1083    }
1084}
1085
1086impl<M: BufferMode> Drop for SegmentWriter<M> {
1087    fn drop(&mut self) {
1088        if self.dropped_events > 0 {
1089            rate_limited!(Duration::from_secs(60), {
1090                tracing::info!(
1091                    target: "dial9_telemetry",
1092                    dropped_events = self.dropped_events,
1093                    "SegmentWriter dropped events after finalization"
1094                );
1095            });
1096        }
1097    }
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103    use dial9_trace_format::TraceEvent;
1104    use std::collections::HashMap;
1105    use std::io::Read;
1106    use tempfile::TempDir;
1107
1108    /// A minimal data event for exercising the writer, distinct from the bus's
1109    /// own framing events (`ClockSyncEvent`/`SegmentMetadataEvent`) so the
1110    /// decode helper can tell real events from the per-segment framing.
1111    #[derive(TraceEvent)]
1112    #[traceevent(wire_slot)]
1113    struct TestEvent {
1114        #[traceevent(timestamp)]
1115        timestamp_ns: u64,
1116        value: u64,
1117    }
1118
1119    /// Decoded view of a trace, classified by frame name via the registry.
1120    #[derive(Debug)]
1121    enum Decoded {
1122        ClockSync {
1123            timestamp_ns: u64,
1124            realtime_ns: u64,
1125        },
1126        SegmentMetadata {
1127            timestamp_ns: u64,
1128            entries: HashMap<String, String>,
1129        },
1130        Data {
1131            timestamp_ns: u64,
1132        },
1133    }
1134
1135    fn decode_all(data: &[u8]) -> Vec<Decoded> {
1136        use dial9_trace_format::decoder::{DecodedFrameRef, Decoder};
1137        use dial9_trace_format::types::FieldValueRef;
1138
1139        let mut dec = Decoder::new(data).expect("valid trace header");
1140        let mut out = Vec::new();
1141        while let Some(frame) = dec.next_frame_ref().expect("decode frame") {
1142            let DecodedFrameRef::Event {
1143                type_id,
1144                timestamp_ns,
1145                values,
1146            } = frame
1147            else {
1148                continue;
1149            };
1150            let ts = timestamp_ns;
1151            let name = dec.registry().get(type_id).map(|s| s.name());
1152            match name {
1153                Some("ClockSyncEvent") => {
1154                    let realtime_ns = match values.first() {
1155                        Some(FieldValueRef::Varint(v)) => *v,
1156                        other => panic!("ClockSyncEvent realtime_ns: {other:?}"),
1157                    };
1158                    out.push(Decoded::ClockSync {
1159                        timestamp_ns: ts,
1160                        realtime_ns,
1161                    });
1162                }
1163                Some("SegmentMetadataEvent") => {
1164                    let entries = match values.first() {
1165                        Some(FieldValueRef::StringMap(m)) => m
1166                            .iter()
1167                            .map(|(k, v)| (k.to_string(), v.to_string()))
1168                            .collect(),
1169                        other => panic!("SegmentMetadataEvent entries: {other:?}"),
1170                    };
1171                    out.push(Decoded::SegmentMetadata {
1172                        timestamp_ns: ts,
1173                        entries,
1174                    });
1175                }
1176                _ => out.push(Decoded::Data { timestamp_ns: ts }),
1177            }
1178        }
1179        out
1180    }
1181
1182    /// Encode a single event into a self-contained batch (header + event),
1183    /// matching the format produced by ThreadLocalBuffer.
1184    fn test_batch() -> Batch {
1185        let mut enc = Encoder::new_to(Vec::new()).unwrap();
1186        enc.write(&TestEvent {
1187            timestamp_ns: 1000,
1188            value: 0,
1189        })
1190        .unwrap();
1191        Batch::new(enc.into_inner(), 1)
1192    }
1193
1194    fn rotating_file(base: &std::path::Path, i: u32) -> String {
1195        format!("{}.{}.bin", base.display(), i)
1196    }
1197
1198    /// Read all data (non-framing) events from a trace file.
1199    fn read_trace_events(path: &str) -> Vec<Decoded> {
1200        let data = std::fs::read(path).unwrap();
1201        decode_all(&data)
1202            .into_iter()
1203            .filter(|e| matches!(e, Decoded::Data { .. }))
1204            .collect()
1205    }
1206
1207    /// Total size of all trace files (.bin and .active) in a directory.
1208    fn total_disk_usage(dir: &std::path::Path) -> u64 {
1209        std::fs::read_dir(dir)
1210            .unwrap()
1211            .filter_map(|e| e.ok())
1212            .filter(|e| {
1213                let p = e.path();
1214                p.extension()
1215                    .is_some_and(|ext| ext == "bin" || ext == "active")
1216            })
1217            .map(|e| e.metadata().unwrap().len())
1218            .sum()
1219    }
1220
1221    /// Write one batch to a temp file and return the file size.
1222    /// This captures the actual overhead (header + schema + event) so tests
1223    /// don't depend on hardcoded format sizes.
1224    fn single_event_file_size() -> u64 {
1225        let dir = TempDir::new().unwrap();
1226        let path = dir.path().join("probe.bin");
1227        let mut w = DiskBuffer::single_file(&path).unwrap();
1228        w.write_encoded_batch(&test_batch()).unwrap();
1229        w.flush().unwrap();
1230        std::fs::metadata(w.current_active_path()).unwrap().len()
1231    }
1232
1233    #[test]
1234    fn test_writer_creation() {
1235        let dir = TempDir::new().unwrap();
1236        let path = dir.path().join("test_trace_v2.bin");
1237        let writer = DiskBuffer::single_file(&path);
1238        assert!(writer.is_ok());
1239    }
1240
1241    #[test]
1242    fn derive_max_file_size_caps_large_budgets_at_100_mib() {
1243        assert_eq!(
1244            derive_max_file_size(1024 * BYTES_PER_MIB),
1245            100 * BYTES_PER_MIB
1246        );
1247    }
1248
1249    #[test]
1250    fn derive_max_file_size_uses_quarter_of_small_budgets() {
1251        assert_eq!(derive_max_file_size(64 * BYTES_PER_MIB), 16 * BYTES_PER_MIB);
1252    }
1253
1254    #[test]
1255    fn builder_defaults_max_file_size_from_total_size() {
1256        let dir = TempDir::new().unwrap();
1257        let total = 64 * BYTES_PER_MIB;
1258        let writer = DiskBuffer::builder()
1259            .base_path(dir.path())
1260            .max_total_size(total)
1261            .build()
1262            .expect("builder should succeed without max_file_size");
1263        assert_eq!(writer.max_file_size, derive_max_file_size(total));
1264    }
1265
1266    #[test]
1267    fn builder_honors_explicit_max_file_size() {
1268        let dir = TempDir::new().unwrap();
1269        let writer = DiskBuffer::builder()
1270            .base_path(dir.path())
1271            .max_file_size(7 * BYTES_PER_MIB)
1272            .max_total_size(64 * BYTES_PER_MIB)
1273            .build()
1274            .expect("builder should succeed");
1275        assert_eq!(writer.max_file_size, 7 * BYTES_PER_MIB);
1276    }
1277
1278    #[test]
1279    fn test_write_event() {
1280        let dir = TempDir::new().unwrap();
1281        let path = dir.path().join("test_event_v2.bin");
1282        let mut writer = DiskBuffer::single_file(&path).unwrap();
1283
1284        writer.write_encoded_batch(&test_batch()).unwrap();
1285        writer.flush().unwrap();
1286
1287        let metadata = std::fs::metadata(writer.current_active_path()).unwrap();
1288        assert!(
1289            metadata.len() > 0,
1290            "file should not be empty after writing an event"
1291        );
1292    }
1293
1294    #[test]
1295    fn test_write_batch_sizes() {
1296        let dir = TempDir::new().unwrap();
1297        let path = dir.path().join("test_batch_v2.bin");
1298        let mut writer = DiskBuffer::single_file(&path).unwrap();
1299
1300        let one_event_size = single_event_file_size();
1301
1302        for _ in 0..2 {
1303            writer.write_encoded_batch(&test_batch()).unwrap();
1304        }
1305        writer.flush().unwrap();
1306
1307        let metadata = std::fs::metadata(writer.current_active_path()).unwrap();
1308        // Two events should be larger than one event
1309        assert!(metadata.len() > one_event_size);
1310    }
1311
1312    #[test]
1313    fn test_binary_format_header() {
1314        let dir = TempDir::new().unwrap();
1315        let path = dir.path().join("test_format_v2.bin");
1316        let writer = DiskBuffer::single_file(&path).unwrap();
1317        let active = writer.current_active_path().to_owned();
1318        drop(writer);
1319
1320        let mut file = std::fs::File::open(&active).unwrap();
1321        let mut magic = [0u8; 4];
1322        file.read_exact(&mut magic).unwrap();
1323        assert_eq!(&magic, b"TRC\0");
1324    }
1325
1326    #[test]
1327    fn test_rotating_writer_creation() {
1328        let dir = TempDir::new().unwrap();
1329        let mut writer = DiskBuffer::builder()
1330            .base_path(dir.path())
1331            .max_file_size(1024)
1332            .max_total_size(4096)
1333            .build()
1334            .unwrap();
1335        writer.finalize().unwrap();
1336
1337        // No real events were written, so finalize removes the empty segment.
1338        assert!(
1339            !dir.path().join("trace.0.bin").exists(),
1340            "empty segment should not be sealed"
1341        );
1342        assert!(
1343            !dir.path().join("trace.0.bin.active").exists(),
1344            "active file should be removed"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_rotating_writer_rotation() {
1350        let dir = TempDir::new().unwrap();
1351        let base = dir.path().join("trace");
1352        // Set max_file_size to fit ~1 event so rotation triggers quickly
1353        let one_event = single_event_file_size();
1354        let mut writer = DiskBuffer::builder()
1355            .base_path(dir.path())
1356            .max_file_size(one_event)
1357            .max_total_size(100_000)
1358            .build()
1359            .unwrap();
1360
1361        for _ in 0..3 {
1362            writer.write_encoded_batch(&test_batch()).unwrap();
1363        }
1364        writer.finalize().unwrap();
1365
1366        // All 3 events should be readable across rotated files
1367        let total: usize = (0..10)
1368            .map(|i| {
1369                let f = rotating_file(&base, i);
1370                if std::path::Path::new(&f).exists() {
1371                    read_trace_events(&f).len()
1372                } else {
1373                    0
1374                }
1375            })
1376            .sum();
1377        assert_eq!(total, 3);
1378    }
1379
1380    #[test]
1381    fn test_rotating_writer_eviction() {
1382        let dir = TempDir::new().unwrap();
1383        let base = dir.path().join("trace");
1384        let one_event = single_event_file_size();
1385        let max_file_size = one_event;
1386        let max_total_size = max_file_size * 3;
1387        let mut writer = DiskBuffer::builder()
1388            .base_path(dir.path())
1389            .max_file_size(max_file_size)
1390            .max_total_size(max_total_size)
1391            .build()
1392            .unwrap();
1393
1394        for _ in 0..10 {
1395            writer.write_encoded_batch(&test_batch()).unwrap();
1396        }
1397        writer.finalize().unwrap();
1398
1399        // Key invariant: total disk usage stays within budget
1400        assert!(total_disk_usage(dir.path()) <= max_total_size);
1401
1402        // Oldest files should be evicted
1403        assert!(!std::path::Path::new(&rotating_file(&base, 0)).exists());
1404    }
1405
1406    #[test]
1407    fn test_rotating_writer_stops_when_over_budget() {
1408        let dir = TempDir::new().unwrap();
1409        let base = dir.path().join("trace");
1410        let one_event = single_event_file_size();
1411        // Small file size to force rotation, total budget fits ~1 file
1412        let max_file_size = one_event;
1413        let max_total_size = one_event + 5;
1414        let mut writer = DiskBuffer::builder()
1415            .base_path(dir.path())
1416            .max_file_size(max_file_size)
1417            .max_total_size(max_total_size)
1418            .build()
1419            .unwrap();
1420
1421        for _ in 0..100 {
1422            writer.write_encoded_batch(&test_batch()).unwrap();
1423        }
1424        writer.finalize().unwrap();
1425
1426        // Should have stopped writing — total events across all files < 100
1427        let total: usize = (0..100)
1428            .map(|i| {
1429                let f = rotating_file(&base, i);
1430                if std::path::Path::new(&f).exists() {
1431                    read_trace_events(&f).len()
1432                } else {
1433                    0
1434                }
1435            })
1436            .sum();
1437        assert!(
1438            total < 100,
1439            "should have stopped writing, got {total} events"
1440        );
1441    }
1442
1443    /// Bug: write_encoded_batch sets stopped=true when total_size slightly exceeds
1444    /// max_total_size, without attempting eviction. This happens right after
1445    /// rotate() + evict_oldest() brings total_size just under budget, then the
1446    /// first batch in the new file pushes it a few bytes over. The writer
1447    /// permanently stops even though eviction could free space.
1448    ///
1449    /// Reproduces the stress test failure: 64-worker runtime with 1MB segments
1450    /// and 100MB budget stops producing segments after ~100 rotations.
1451    #[test]
1452    fn test_writer_stops_on_tiny_overshoot_after_eviction() {
1453        let dir = TempDir::new().unwrap();
1454        // Use max_file_size that doesn't evenly divide by batch size,
1455        // so files end up slightly under max_file_size (with leftover bytes).
1456        // Over 100 files, these leftovers accumulate and push total_size
1457        // past max_total_size after eviction.
1458        let max_file_size = 200;
1459        let num_files = 100u64;
1460        let max_total_size = max_file_size * num_files;
1461        let mut writer = DiskBuffer::builder()
1462            .base_path(dir.path())
1463            .max_file_size(max_file_size)
1464            .max_total_size(max_total_size)
1465            .build()
1466            .unwrap();
1467
1468        // Write many batches. The batch size doesn't divide evenly into
1469        // (max_file_size - header), so each file wastes a few bytes. After
1470        // 100 rotations, total_size drifts above max_total_size.
1471        for i in 0..5000 {
1472            writer.write_encoded_batch(&test_batch()).unwrap();
1473            if matches!(writer.state, WriterState::Finished) {
1474                panic!(
1475                    "Writer stopped at batch {i}! total_size={}, max_total_size={}, \
1476                     closed_files={}. \
1477                     write_encoded_batch should try eviction before stopping.",
1478                    writer.total_size(),
1479                    max_total_size,
1480                    writer.closed_files.len()
1481                );
1482            }
1483        }
1484    }
1485
1486    #[test]
1487    fn test_rotating_writer_file_naming() {
1488        let dir = TempDir::new().unwrap();
1489        let base = dir.path().join("trace");
1490        let one_event = single_event_file_size();
1491        let mut writer = DiskBuffer::builder()
1492            .base_path(dir.path())
1493            .max_file_size(one_event)
1494            .max_total_size(100_000)
1495            .build()
1496            .unwrap();
1497
1498        for _ in 0..5 {
1499            writer.write_encoded_batch(&test_batch()).unwrap();
1500        }
1501        writer.finalize().unwrap();
1502
1503        // Should have created multiple files with sequential naming
1504        assert!(
1505            std::path::Path::new(&rotating_file(&base, 0)).exists(),
1506            "File 0 should exist"
1507        );
1508        // All events should be readable
1509        let total: usize = (0..10)
1510            .map(|i| {
1511                let f = rotating_file(&base, i);
1512                if std::path::Path::new(&f).exists() {
1513                    read_trace_events(&f).len()
1514                } else {
1515                    0
1516                }
1517            })
1518            .sum();
1519        assert_eq!(total, 5);
1520    }
1521
1522    #[test]
1523    fn test_write_batch_across_rotation_boundary() {
1524        let dir = TempDir::new().unwrap();
1525        let base = dir.path().join("trace");
1526        let one_event = single_event_file_size();
1527        let mut writer = DiskBuffer::builder()
1528            .base_path(dir.path())
1529            .max_file_size(one_event)
1530            .max_total_size(100_000)
1531            .build()
1532            .unwrap();
1533
1534        for _ in 0..3 {
1535            writer.write_encoded_batch(&test_batch()).unwrap();
1536        }
1537        writer.finalize().unwrap();
1538
1539        // All 3 events should be readable across the rotated files.
1540        let total: usize = (0..10)
1541            .map(|i| {
1542                let f = rotating_file(&base, i);
1543                if std::path::Path::new(&f).exists() {
1544                    read_trace_events(&f).len()
1545                } else {
1546                    0
1547                }
1548            })
1549            .sum();
1550        assert_eq!(total, 3);
1551    }
1552
1553    #[test]
1554    fn test_rotated_files_have_valid_headers() {
1555        let dir = TempDir::new().unwrap();
1556        let base = dir.path().join("trace");
1557        let one_event = single_event_file_size();
1558        let mut writer = DiskBuffer::builder()
1559            .base_path(dir.path())
1560            .max_file_size(one_event)
1561            .max_total_size(100_000)
1562            .build()
1563            .unwrap();
1564
1565        for _ in 0..3 {
1566            writer.write_encoded_batch(&test_batch()).unwrap();
1567        }
1568        writer.finalize().unwrap();
1569
1570        // Each rotated file must be a self-contained, readable trace.
1571        let total: usize = (0..10)
1572            .map(|i| {
1573                let f = rotating_file(&base, i);
1574                if std::path::Path::new(&f).exists() {
1575                    read_trace_events(&f).len() // panics if corrupt
1576                } else {
1577                    0
1578                }
1579            })
1580            .sum();
1581        assert_eq!(total, 3);
1582    }
1583
1584    #[test]
1585    fn test_flush_after_stop() {
1586        let dir = TempDir::new().unwrap();
1587        // Total budget smaller than one file — stops immediately
1588        let mut writer = DiskBuffer::builder()
1589            .base_path(dir.path())
1590            .max_file_size(10_000)
1591            .max_total_size(50)
1592            .build()
1593            .unwrap();
1594
1595        for _ in 0..5 {
1596            writer.write_encoded_batch(&test_batch()).unwrap();
1597        }
1598        // Repeated flush after stop should not error
1599        assert!(writer.flush().is_ok());
1600        assert!(writer.flush().is_ok());
1601    }
1602
1603    #[test]
1604    fn test_mixed_event_sizes() {
1605        let dir = TempDir::new().unwrap();
1606        let base = dir.path().join("trace");
1607        let one_event = single_event_file_size();
1608        let mut writer = DiskBuffer::builder()
1609            .base_path(dir.path())
1610            .max_file_size(one_event)
1611            .max_total_size(100_000)
1612            .build()
1613            .unwrap();
1614
1615        for _ in 0..3 {
1616            writer.write_encoded_batch(&test_batch()).unwrap();
1617        }
1618        writer.finalize().unwrap();
1619
1620        // All events should be readable across files.
1621        let mut total = 0;
1622        for i in 0..10 {
1623            let f = rotating_file(&base, i);
1624            if std::path::Path::new(&f).exists() {
1625                total += read_trace_events(&f).len();
1626            }
1627        }
1628        assert_eq!(total, 3);
1629    }
1630
1631    #[test]
1632    fn test_event_exactly_on_max_file_size_boundary() {
1633        let dir = TempDir::new().unwrap();
1634        let base = dir.path().join("trace");
1635        let one_event = single_event_file_size();
1636        // Exactly fits one event file — second event triggers rotation
1637        let mut writer = DiskBuffer::builder()
1638            .base_path(dir.path())
1639            .max_file_size(one_event)
1640            .max_total_size(100_000)
1641            .build()
1642            .unwrap();
1643
1644        for _ in 0..2 {
1645            writer.write_encoded_batch(&test_batch()).unwrap();
1646        }
1647        writer.finalize().unwrap();
1648
1649        // Both events readable across files
1650        let total: usize = (0..10)
1651            .map(|i| {
1652                let f = rotating_file(&base, i);
1653                if std::path::Path::new(&f).exists() {
1654                    read_trace_events(&f).len()
1655                } else {
1656                    0
1657                }
1658            })
1659            .sum();
1660        assert_eq!(total, 2);
1661    }
1662
1663    #[test]
1664    fn test_active_suffix_while_writing() {
1665        let dir = TempDir::new().unwrap();
1666        let mut writer = DiskBuffer::builder()
1667            .base_path(dir.path())
1668            .max_file_size(1024)
1669            .max_total_size(100000)
1670            .build()
1671            .unwrap();
1672        writer.write_encoded_batch(&test_batch()).unwrap();
1673        writer.flush().unwrap();
1674
1675        // Current file should have .active suffix
1676        let active = dir.path().join("trace.0.bin.active");
1677        assert!(active.exists(), "active file should exist while writing");
1678        let sealed = dir.path().join("trace.0.bin");
1679        assert!(!sealed.exists(), "sealed file should not exist yet");
1680    }
1681
1682    #[test]
1683    fn test_rotation_seals_previous_file() {
1684        let dir = TempDir::new().unwrap();
1685        let one_event = single_event_file_size();
1686        let mut writer = DiskBuffer::builder()
1687            .base_path(dir.path())
1688            .max_file_size(one_event)
1689            .max_total_size(100_000)
1690            .build()
1691            .unwrap();
1692
1693        // Write 2 events — triggers rotation after first
1694        writer.write_encoded_batch(&test_batch()).unwrap();
1695        writer.write_encoded_batch(&test_batch()).unwrap();
1696        writer.flush().unwrap();
1697
1698        // First file should be sealed (.bin), second should be active
1699        assert!(
1700            dir.path().join("trace.0.bin").exists(),
1701            "rotated file should be sealed"
1702        );
1703        assert!(
1704            !dir.path().join("trace.0.bin.active").exists(),
1705            "rotated file should not be active"
1706        );
1707        assert!(
1708            dir.path().join("trace.1.bin.active").exists(),
1709            "current file should be active"
1710        );
1711        assert!(
1712            !dir.path().join("trace.1.bin").exists(),
1713            "current file should not be sealed"
1714        );
1715    }
1716
1717    #[test]
1718    fn test_finalize_renames_current_file() {
1719        let dir = TempDir::new().unwrap();
1720        let mut writer = DiskBuffer::builder()
1721            .base_path(dir.path())
1722            .max_file_size(1024)
1723            .max_total_size(100000)
1724            .build()
1725            .unwrap();
1726        writer.write_encoded_batch(&test_batch()).unwrap();
1727        writer.finalize().unwrap();
1728
1729        assert!(
1730            dir.path().join("trace.0.bin").exists(),
1731            "file should be sealed after finalize()"
1732        );
1733        assert!(
1734            !dir.path().join("trace.0.bin.active").exists(),
1735            "active file should be gone after finalize()"
1736        );
1737    }
1738
1739    #[test]
1740    fn test_finalize_removes_empty_segment_after_rotation() {
1741        let dir = TempDir::new().unwrap();
1742        // Small max_file_size so one event triggers rotation.
1743        let mut writer = DiskBuffer::builder()
1744            .base_path(dir.path())
1745            .max_file_size(1)
1746            .max_total_size(100_000)
1747            .build()
1748            .unwrap();
1749        // Write an event — this fills segment 0 and triggers rotation to segment 1.
1750        writer.write_encoded_batch(&test_batch()).unwrap();
1751        // Segment 0 is sealed, segment 1 is active with only header + metadata.
1752        assert!(dir.path().join("trace.0.bin").exists());
1753        assert!(dir.path().join("trace.1.bin.active").exists());
1754
1755        // Finalize should remove the empty segment 1 instead of sealing it.
1756        writer.finalize().unwrap();
1757        assert!(
1758            !dir.path().join("trace.1.bin").exists(),
1759            "empty segment should not be sealed"
1760        );
1761        assert!(
1762            !dir.path().join("trace.1.bin.active").exists(),
1763            "empty active file should be removed"
1764        );
1765        // Segment 0 should still exist.
1766        assert!(dir.path().join("trace.0.bin").exists());
1767    }
1768
1769    #[test]
1770    fn test_single_file_no_active_suffix() {
1771        let dir = TempDir::new().unwrap();
1772        let path = dir.path().join("test.bin");
1773        let mut writer = DiskBuffer::single_file(&path).unwrap();
1774        writer.write_encoded_batch(&test_batch()).unwrap();
1775        writer.flush().unwrap();
1776        writer.finalize().unwrap();
1777
1778        // single_file seals to test.0.bin after finalize, no leftover .active
1779        assert!(dir.path().join("test.0.bin").exists());
1780        assert!(!dir.path().join("test.0.bin.active").exists());
1781    }
1782
1783    #[test]
1784    #[cfg(feature = "pipeline")]
1785    fn test_single_file_sealed_segment_discoverable_by_worker() {
1786        use crate::sealed::find_sealed_segments;
1787
1788        let dir = TempDir::new().unwrap();
1789        let path = dir.path().join("trace.bin");
1790        let mut writer = DiskBuffer::single_file(&path).unwrap();
1791        writer.write_encoded_batch(&test_batch()).unwrap();
1792        writer.flush().unwrap();
1793        writer.finalize().unwrap();
1794
1795        let segments = find_sealed_segments(dir.path(), "trace").unwrap();
1796        assert_eq!(
1797            segments.len(),
1798            1,
1799            "worker should find exactly one sealed segment"
1800        );
1801        assert_eq!(segments[0].path, dir.path().join("trace.0.bin"));
1802    }
1803
1804    #[test]
1805    fn test_segment_metadata_roundtrip() {
1806        let dir = TempDir::new().unwrap();
1807        let base = dir.path().join("trace");
1808        let mut writer = DiskBuffer::builder()
1809            .base_path(dir.path())
1810            .max_file_size(100_000)
1811            .max_total_size(100_000)
1812            .segment_metadata(vec![
1813                ("service".into(), "checkout-api".into()),
1814                ("host".into(), "i-0abc123".into()),
1815            ])
1816            .build()
1817            .unwrap();
1818        writer.write_encoded_batch(&test_batch()).unwrap();
1819        writer.flush().unwrap();
1820        writer.finalize().unwrap();
1821
1822        let all_events = decode_all(&std::fs::read(format!("{}.0.bin", base.display())).unwrap());
1823        let metadata: Vec<_> = all_events
1824            .iter()
1825            .filter_map(|e| match e {
1826                Decoded::SegmentMetadata { entries, .. } => Some(entries.clone()),
1827                _ => None,
1828            })
1829            .collect();
1830        // One written when the segment opened, one when it was sealed.
1831        assert_eq!(metadata.len(), 2);
1832        assert!(
1833            metadata[0].get("service").map(String::as_str) == Some("checkout-api"),
1834            "missing service entry: {:?}",
1835            metadata[0]
1836        );
1837        assert!(
1838            metadata[0].get("host").map(String::as_str) == Some("i-0abc123"),
1839            "missing host entry: {:?}",
1840            metadata[0]
1841        );
1842        assert_eq!(
1843            metadata[0].get(DIAL9_VERSION_KEY).map(String::as_str),
1844            Some(DIAL9_VERSION_VALUE),
1845            "missing built-in dial9.dial9-tokio-telemetry.version: {:?}",
1846            metadata[0]
1847        );
1848    }
1849
1850    #[test]
1851    fn test_segment_metadata_written_in_every_rotated_file() {
1852        let dir = TempDir::new().unwrap();
1853        let one_event = single_event_file_size();
1854        let mut writer = DiskBuffer::builder()
1855            .base_path(dir.path())
1856            .max_file_size(one_event)
1857            .max_total_size(100_000)
1858            .segment_metadata(vec![("k".into(), "v".into())])
1859            .build()
1860            .unwrap();
1861
1862        for _ in 0..5 {
1863            writer.write_encoded_batch(&test_batch()).unwrap();
1864        }
1865        writer.flush().unwrap();
1866        writer.finalize().unwrap();
1867
1868        let mut files: Vec<_> = std::fs::read_dir(dir.path())
1869            .unwrap()
1870            .filter_map(|e| e.ok())
1871            .map(|e| e.path())
1872            .filter(|p| p.extension().is_some_and(|ext| ext == "bin"))
1873            .collect();
1874        files.sort();
1875        assert!(files.len() >= 2, "expected at least 2 files from rotation");
1876
1877        for file in &files {
1878            let all_events = decode_all(&std::fs::read(file).unwrap());
1879            let has_metadata = all_events.iter().any(|e| match e {
1880                Decoded::SegmentMetadata { entries, .. } => {
1881                    entries.get("k").map(String::as_str) == Some("v")
1882                }
1883                _ => false,
1884            });
1885            assert!(has_metadata, "{}: expected SegmentMetadata", file.display());
1886        }
1887    }
1888
1889    #[test]
1890    fn test_dynamic_metadata_merged_on_rotation() {
1891        let dir = TempDir::new().unwrap();
1892        let one_event = single_event_file_size();
1893        let mut writer = DiskBuffer::builder()
1894            .base_path(dir.path())
1895            .max_file_size(one_event)
1896            .max_total_size(100_000)
1897            .segment_metadata(vec![("service".into(), "myapp".into())])
1898            .build()
1899            .unwrap();
1900
1901        // Simulate the flush thread merging static + runtime→worker entries.
1902        let mut merged = writer.segment_metadata().to_vec();
1903        merged.push(("runtime.main".into(), "0,1,2,3".into()));
1904        writer.update_segment_metadata(merged);
1905
1906        // Write enough events to trigger rotation — rotated segments should
1907        // contain both static and dynamic metadata.
1908        for _ in 0..4 {
1909            writer.write_encoded_batch(&test_batch()).unwrap();
1910        }
1911        writer.flush().unwrap();
1912        writer.finalize().unwrap();
1913
1914        let mut files: Vec<_> = std::fs::read_dir(dir.path())
1915            .unwrap()
1916            .filter_map(|e| e.ok())
1917            .map(|e| e.path())
1918            .filter(|p| p.extension().is_some_and(|ext| ext == "bin"))
1919            .collect();
1920        files.sort();
1921        assert!(files.len() >= 2, "expected at least 2 files from rotation");
1922
1923        // First segment was constructed before update_dynamic_metadata, so
1924        // it only has static metadata. Rotated segments have both.
1925        for file in &files[1..] {
1926            let all_events = decode_all(&std::fs::read(file).unwrap());
1927            let meta: Vec<_> = all_events
1928                .iter()
1929                .filter_map(|e| match e {
1930                    Decoded::SegmentMetadata { entries, .. } => Some(entries.clone()),
1931                    _ => None,
1932                })
1933                .collect();
1934            // One written when the segment opened, one when it was sealed.
1935            assert_eq!(
1936                meta.len(),
1937                2,
1938                "{}: expected 2 metadata events",
1939                file.display()
1940            );
1941            assert!(
1942                meta[0].get("service").map(String::as_str) == Some("myapp"),
1943                "{}: missing static metadata",
1944                file.display()
1945            );
1946            assert!(
1947                meta[0].get("runtime.main").map(String::as_str) == Some("0,1,2,3"),
1948                "{}: missing dynamic runtime worker metadata",
1949                file.display()
1950            );
1951        }
1952    }
1953
1954    #[test]
1955    fn test_segment_metadata_empty_entries() {
1956        let dir = TempDir::new().unwrap();
1957        let path = dir.path().join("trace.bin");
1958        let mut writer = DiskBuffer::single_file(&path).unwrap();
1959        writer.write_encoded_batch(&test_batch()).unwrap();
1960        writer.flush().unwrap();
1961
1962        let all_events = decode_all(&std::fs::read(writer.current_active_path()).unwrap());
1963        let data_count = all_events
1964            .iter()
1965            .filter(|e| matches!(e, Decoded::Data { .. }))
1966            .count();
1967        assert_eq!(data_count, 1);
1968        // Metadata should be present and carry only the built-in dial9.dial9-tokio-telemetry.version entry
1969        // (no user-supplied entries via single_file()).
1970        let metadata: Vec<_> = all_events
1971            .iter()
1972            .filter_map(|e| match e {
1973                Decoded::SegmentMetadata { entries, .. } => Some(entries),
1974                _ => None,
1975            })
1976            .collect();
1977        assert_eq!(metadata.len(), 1);
1978        assert_eq!(
1979            metadata[0].get(DIAL9_VERSION_KEY).map(String::as_str),
1980            Some(DIAL9_VERSION_VALUE)
1981        );
1982    }
1983
1984    /// When the background worker has renamed a sealed `.bin` to `.bin.gz`,
1985    /// eviction should clean up the `.gz` variant instead of silently leaking it.
1986    #[test]
1987    fn test_eviction_removes_gz_variant() {
1988        let dir = TempDir::new().unwrap();
1989        let one_event = single_event_file_size();
1990        let max_file_size = one_event;
1991        // Budget fits many files so segment 0 is not immediately evicted.
1992        let max_total_size = max_file_size * 100;
1993        let mut writer = DiskBuffer::builder()
1994            .base_path(dir.path())
1995            .max_file_size(max_file_size)
1996            .max_total_size(max_total_size)
1997            .build()
1998            .unwrap();
1999
2000        // Write two batches: the first fills segment 0, the second triggers
2001        // rotation (sealing segment 0 as trace.0.bin) and starts segment 1.
2002        writer.write_encoded_batch(&test_batch()).unwrap();
2003        writer.write_encoded_batch(&test_batch()).unwrap();
2004        // Segment 0 is now sealed as trace.0.bin.
2005
2006        // Simulate the background worker renaming trace.0.bin → trace.0.bin.gz.
2007        let seg0 = dir.path().join("trace.0.bin");
2008        let seg0_gz = dir.path().join("trace.0.bin.gz");
2009        assert!(seg0.exists(), "trace.0.bin should exist after rotation");
2010        std::fs::rename(&seg0, &seg0_gz).unwrap();
2011
2012        // Now shrink the budget so the next rotation triggers eviction of
2013        // segment 0 (which has been renamed to .bin.gz on disk).
2014        writer.max_total_size = max_file_size;
2015        for _ in 0..3 {
2016            writer.write_encoded_batch(&test_batch()).unwrap();
2017        }
2018        writer.finalize().unwrap();
2019
2020        // The .bin.gz file should have been cleaned up by eviction.
2021        assert!(!seg0_gz.exists(), "trace.0.bin.gz should have been evicted");
2022    }
2023
2024    /// Eviction must never drop below the most-recent segment, even when that
2025    /// single segment alone exceeds `max_total_size`. In that case it retains
2026    /// the segment on disk (so on-disk usage legitimately exceeds the budget)
2027    /// and signals "stop writing" by transitioning to `Finished`.
2028    ///
2029    /// This is the floor that makes an end-to-end `on-disk bytes <=
2030    /// max_total_size` assertion unsound — see `tests/writeback_no_leaked_gz.rs`.
2031    #[test]
2032    fn test_eviction_keeps_most_recent_segment_when_over_budget() {
2033        let dir = TempDir::new().unwrap();
2034        let one_event = single_event_file_size();
2035        // No rotation (huge per-file size) so the single active segment is the
2036        // only one; a budget smaller than one segment forces the floor.
2037        let max_file_size = u64::MAX;
2038        let max_total_size = one_event / 2;
2039        assert!(
2040            max_total_size < one_event,
2041            "test setup: budget must be smaller than a single segment"
2042        );
2043        let mut writer = DiskBuffer::builder()
2044            .base_path(dir.path())
2045            .max_file_size(max_file_size)
2046            .max_total_size(max_total_size)
2047            .build()
2048            .unwrap();
2049
2050        writer.write_encoded_batch(&test_batch()).unwrap();
2051        // The lone active segment already exceeds the total budget.
2052        assert!(
2053            writer.total_size() > max_total_size,
2054            "single segment ({}) should exceed budget ({max_total_size})",
2055            writer.total_size()
2056        );
2057
2058        // Eviction has no closed segments to drop and must NOT delete the
2059        // current (most-recent) segment. It signals "stop" instead.
2060        writer.evict_oldest().unwrap();
2061
2062        assert!(
2063            matches!(writer.state, WriterState::Finished),
2064            "writer should stop once even the most-recent segment exceeds budget"
2065        );
2066        // The most-recent segment is retained on disk despite exceeding the
2067        // budget — eviction never drops below one segment.
2068        assert!(
2069            std::path::Path::new(&writer.current_active_path()).exists(),
2070            "the most-recent segment must not be evicted"
2071        );
2072        assert!(
2073            total_disk_usage(dir.path()) > max_total_size,
2074            "retained segment is expected to push on-disk usage over the budget"
2075        );
2076    }
2077
2078    // ---- Time-based rotation tests ----
2079
2080    #[tokio::test(start_paused = true)]
2081    async fn test_time_rotation_triggers_on_expired_boundary() {
2082        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2083        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2084
2085        let dir = TempDir::new().unwrap();
2086        let base = dir.path().join("trace");
2087        let mut writer = DiskBuffer::builder()
2088            .base_path(dir.path())
2089            .max_file_size(u64::MAX)
2090            .max_total_size(100_000)
2091            .rotation_period(Duration::from_secs(60))
2092            .build()
2093            .unwrap();
2094
2095        writer.write_encoded_batch(&test_batch()).unwrap();
2096        writer.flush().unwrap();
2097        let initial_index = writer.next_index;
2098
2099        // Advance past the 60s boundary
2100        tokio::time::advance(Duration::from_secs(61)).await;
2101
2102        // Time-based rotation is now driven by drained(), not write_encoded_batch.
2103        writer.write_encoded_batch(&test_batch()).unwrap();
2104        writer.flush().unwrap();
2105        writer.drained().unwrap();
2106
2107        assert!(
2108            writer.next_index > initial_index,
2109            "expected time-based rotation to trigger"
2110        );
2111        writer.finalize().unwrap();
2112
2113        let total: usize = (0..10)
2114            .map(|i| {
2115                let f = rotating_file(&base, i);
2116                if std::path::Path::new(&f).exists() {
2117                    read_trace_events(&f).len()
2118                } else {
2119                    0
2120                }
2121            })
2122            .sum();
2123        assert_eq!(total, 2);
2124    }
2125
2126    /// The first rotation must happen exactly `rotation_period` after the writer
2127    /// is created, not earlier due to wall-clock alignment. Starting at a non-aligned
2128    /// wall-clock time (UNIX_EPOCH + 22s) with a 60s period and advancing 50s must
2129    /// NOT rotate. So only 50s of monotonic time have elapsed since the writer started.
2130    #[tokio::test(start_paused = true)]
2131    async fn test_first_rotation_uses_monotonic_period_not_wallclock_alignment() {
2132        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2133        let start_wall = std::time::UNIX_EPOCH + Duration::from_secs(22);
2134        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(start_wall));
2135
2136        let dir = TempDir::new().unwrap();
2137        let mut writer = DiskBuffer::builder()
2138            .base_path(dir.path())
2139            .max_file_size(u64::MAX)
2140            .max_total_size(100_000)
2141            .rotation_period(Duration::from_secs(60))
2142            .build()
2143            .unwrap();
2144
2145        writer.write_encoded_batch(&test_batch()).unwrap();
2146        writer.flush().unwrap();
2147        let initial_index = writer.next_index;
2148
2149        // 50s of monotonic time have elapsed under the 60s period, so no rotation.
2150        // On the old wall-clock-aligned implementation this would advance past the
2151        // 60s wall-clock boundary (22s + 50s = 72s ≥ 60s) and incorrectly rotate.
2152        tokio::time::advance(Duration::from_secs(50)).await;
2153
2154        writer.write_encoded_batch(&test_batch()).unwrap();
2155        writer.flush().unwrap();
2156        writer.drained().unwrap();
2157
2158        assert_eq!(
2159            writer.next_index, initial_index,
2160            "rotation must not fire before one full rotation_period of monotonic time has elapsed",
2161        );
2162
2163        // after the period DOES elapse, rotation fires.
2164        tokio::time::advance(Duration::from_secs(11)).await;
2165        writer.write_encoded_batch(&test_batch()).unwrap();
2166        writer.flush().unwrap();
2167        writer.drained().unwrap();
2168        assert!(
2169            writer.next_index > initial_index,
2170            "rotation should fire once a full rotation_period of monotonic time has elapsed",
2171        );
2172
2173        writer.finalize().unwrap();
2174    }
2175
2176    #[tokio::test(start_paused = true)]
2177    async fn test_time_rotation_skips_when_no_real_events() {
2178        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2179        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2180
2181        let dir = TempDir::new().unwrap();
2182        let mut writer = DiskBuffer::builder()
2183            .base_path(dir.path())
2184            .max_file_size(u64::MAX)
2185            .max_total_size(100_000)
2186            .rotation_period(Duration::from_secs(60))
2187            .build()
2188            .unwrap();
2189
2190        // Advance past the boundary without writing any events
2191        tokio::time::advance(Duration::from_secs(120)).await;
2192
2193        let empty_batch = Batch::new(vec![], 0);
2194        writer.write_encoded_batch(&empty_batch).unwrap();
2195
2196        assert_eq!(
2197            writer.next_index, 1,
2198            "should not rotate when no real events exist"
2199        );
2200        writer.finalize().unwrap();
2201    }
2202
2203    #[test]
2204    fn test_size_rotation_still_works_with_time_disabled() {
2205        let dir = TempDir::new().unwrap();
2206        let base = dir.path().join("trace");
2207        let one_event = single_event_file_size();
2208        let mut writer = DiskBuffer::builder()
2209            .base_path(dir.path())
2210            .max_file_size(one_event)
2211            .max_total_size(100_000)
2212            .rotation_period(std::time::Duration::MAX)
2213            .build()
2214            .unwrap();
2215
2216        for _ in 0..3 {
2217            writer.write_encoded_batch(&test_batch()).unwrap();
2218        }
2219        writer.finalize().unwrap();
2220
2221        let total: usize = (0..10)
2222            .map(|i| {
2223                let f = rotating_file(&base, i);
2224                if std::path::Path::new(&f).exists() {
2225                    read_trace_events(&f).len()
2226                } else {
2227                    0
2228                }
2229            })
2230            .sum();
2231        assert_eq!(total, 3);
2232    }
2233
2234    #[tokio::test(start_paused = true)]
2235    async fn test_time_rotation_respects_eviction_budget() {
2236        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2237        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2238
2239        let dir = TempDir::new().unwrap();
2240        let one_event = single_event_file_size();
2241        let mut writer = DiskBuffer::builder()
2242            .base_path(dir.path())
2243            .max_file_size(u64::MAX)
2244            .max_total_size(one_event * 3)
2245            .rotation_period(Duration::from_secs(60))
2246            .build()
2247            .unwrap();
2248
2249        writer.write_encoded_batch(&test_batch()).unwrap();
2250        for _ in 0..5 {
2251            tokio::time::advance(Duration::from_secs(61)).await;
2252            writer.write_encoded_batch(&test_batch()).unwrap();
2253            writer.drained().unwrap();
2254        }
2255        writer.finalize().unwrap();
2256
2257        assert!(
2258            total_disk_usage(dir.path()) <= one_event * 3,
2259            "disk usage should stay within budget"
2260        );
2261    }
2262
2263    #[test]
2264    fn test_builder_rotation_period_default() {
2265        let dir = TempDir::new().unwrap();
2266        let writer = DiskBuffer::builder()
2267            .base_path(dir.path())
2268            .max_file_size(1024)
2269            .max_total_size(100_000)
2270            .build()
2271            .unwrap();
2272        assert_eq!(writer.rotation_period, DEFAULT_ROTATION_PERIOD);
2273    }
2274
2275    #[test]
2276    fn test_new_uses_default_rotation_period() {
2277        let dir = TempDir::new().unwrap();
2278        let writer = DiskBuffer::builder()
2279            .base_path(dir.path())
2280            .max_file_size(1024)
2281            .max_total_size(100_000)
2282            .build()
2283            .unwrap();
2284        assert_eq!(writer.rotation_period, DEFAULT_ROTATION_PERIOD);
2285    }
2286
2287    #[tokio::test(start_paused = true)]
2288    async fn test_finalize_after_time_rotation() {
2289        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2290        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2291
2292        let dir = TempDir::new().unwrap();
2293        let base = dir.path().join("trace");
2294        let mut writer = DiskBuffer::builder()
2295            .base_path(dir.path())
2296            .max_file_size(u64::MAX)
2297            .max_total_size(100_000)
2298            .rotation_period(Duration::from_secs(60))
2299            .build()
2300            .unwrap();
2301
2302        writer.write_encoded_batch(&test_batch()).unwrap();
2303        tokio::time::advance(Duration::from_secs(61)).await;
2304        writer.write_encoded_batch(&test_batch()).unwrap();
2305        writer.drained().unwrap();
2306        writer.finalize().unwrap();
2307
2308        let total: usize = (0..10)
2309            .map(|i| {
2310                let f = rotating_file(&base, i);
2311                if std::path::Path::new(&f).exists() {
2312                    read_trace_events(&f).len()
2313                } else {
2314                    0
2315                }
2316            })
2317            .sum();
2318        assert_eq!(total, 2);
2319    }
2320
2321    #[tokio::test(start_paused = true)]
2322    async fn test_stale_boundary_does_not_rotate_first_event() {
2323        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2324        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2325
2326        let dir = TempDir::new().unwrap();
2327        let base = dir.path().join("trace");
2328        let mut writer = DiskBuffer::builder()
2329            .base_path(dir.path())
2330            .max_file_size(u64::MAX)
2331            .max_total_size(100_000)
2332            .rotation_period(Duration::from_secs(60))
2333            .build()
2334            .unwrap();
2335
2336        // Advance well past the boundary with no events
2337        tokio::time::advance(Duration::from_secs(300)).await;
2338
2339        // First event after the gap — should NOT trigger rotation
2340        writer.write_encoded_batch(&test_batch()).unwrap();
2341        assert_eq!(
2342            writer.next_index, 1,
2343            "first event after idle gap should not trigger immediate rotation"
2344        );
2345
2346        // Second event shortly after — still within the new boundary
2347        writer.write_encoded_batch(&test_batch()).unwrap();
2348        assert_eq!(
2349            writer.next_index, 1,
2350            "second event should still be in the same segment"
2351        );
2352
2353        writer.finalize().unwrap();
2354
2355        let events = read_trace_events(&rotating_file(&base, 0));
2356        assert_eq!(events.len(), 2, "both events should be in segment 0");
2357    }
2358
2359    /// `segment.complete` from the last metadata event in a sealed segment.
2360    fn seal_verdict(path: &str) -> Option<String> {
2361        let all = decode_all(&std::fs::read(path).unwrap());
2362        let entries = all
2363            .iter()
2364            .rev()
2365            .find_map(|e| match e {
2366                Decoded::SegmentMetadata { entries, .. } => Some(entries.clone()),
2367                _ => None,
2368            })
2369            .expect("sealed segment has metadata");
2370        entries.get(SEGMENT_COMPLETE_KEY).cloned()
2371    }
2372
2373    /// A segment that overflows `max_file_size` records the undrained seal,
2374    /// and its successor records that it inherited the buffered events.
2375    #[test]
2376    fn test_seal_metadata_records_an_overflow_and_its_successor() {
2377        let dir = TempDir::new().unwrap();
2378        let base = dir.path().join("trace");
2379        let mut writer = DiskBuffer::builder()
2380            .base_path(dir.path())
2381            .max_file_size(2_000)
2382            .max_total_size(10_000_000)
2383            .rotation_period(Duration::from_secs(600))
2384            .build()
2385            .unwrap();
2386
2387        // Nothing drives should_drain here, so only maybe_rotate can rotate.
2388        for _ in 0..10_000 {
2389            if writer.next_index > 1 {
2390                break;
2391            }
2392            writer.write_encoded_batch(&test_batch()).unwrap();
2393        }
2394        assert_eq!(writer.next_index, 2, "expected an overflow rotation");
2395
2396        writer.write_encoded_batch(&test_batch()).unwrap();
2397        writer.rotate(SealKind::Drained).unwrap();
2398        writer.write_encoded_batch(&test_batch()).unwrap();
2399        writer.finalize().unwrap();
2400
2401        let t = |b: bool| Some(b.to_string());
2402        assert_eq!(
2403            seal_verdict(&rotating_file(&base, 0)),
2404            t(false),
2405            "overflowed"
2406        );
2407        assert_eq!(
2408            seal_verdict(&rotating_file(&base, 1)),
2409            t(false),
2410            "sealed cleanly, but holds the events segment 0 lost"
2411        );
2412        assert_eq!(
2413            seal_verdict(&rotating_file(&base, 2)),
2414            t(true),
2415            "a clean seal after a clean one"
2416        );
2417    }
2418
2419    #[test]
2420    fn test_seal_metadata_keeps_the_segment_entries() {
2421        let dir = TempDir::new().unwrap();
2422        let base = dir.path().join("trace");
2423        let mut writer = DiskBuffer::builder()
2424            .base_path(dir.path())
2425            .max_file_size(u64::MAX)
2426            .max_total_size(10_000_000)
2427            .segment_metadata(vec![("service".into(), "checkout-api".into())])
2428            .build()
2429            .unwrap();
2430        writer.write_encoded_batch(&test_batch()).unwrap();
2431        writer.finalize().unwrap();
2432
2433        let all = decode_all(&std::fs::read(rotating_file(&base, 0)).unwrap());
2434        let last = all
2435            .iter()
2436            .rev()
2437            .find_map(|e| match e {
2438                Decoded::SegmentMetadata { entries, .. } => Some(entries.clone()),
2439                _ => None,
2440            })
2441            .unwrap();
2442        assert_eq!(
2443            last.get("service").map(String::as_str),
2444            Some("checkout-api")
2445        );
2446        assert_eq!(
2447            last.get(DIAL9_VERSION_KEY).map(String::as_str),
2448            Some(DIAL9_VERSION_VALUE)
2449        );
2450        assert_eq!(
2451            last.get(SEGMENT_COMPLETE_KEY).map(String::as_str),
2452            Some("true")
2453        );
2454    }
2455
2456    /// Rotating from `drained()` leaves both segments complete.
2457    #[tokio::test(start_paused = true)]
2458    async fn test_seal_metadata_records_a_drained_rotation() {
2459        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2460        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2461
2462        let dir = TempDir::new().unwrap();
2463        let base = dir.path().join("trace");
2464        let mut writer = DiskBuffer::builder()
2465            .base_path(dir.path())
2466            .max_file_size(u64::MAX)
2467            .max_total_size(10_000_000)
2468            .rotation_period(Duration::from_secs(60))
2469            .build()
2470            .unwrap();
2471
2472        writer.write_encoded_batch(&test_batch()).unwrap();
2473        tokio::time::advance(Duration::from_secs(61)).await;
2474        assert!(writer.drained().unwrap(), "deadline passed, should rotate");
2475        writer.write_encoded_batch(&test_batch()).unwrap();
2476        writer.finalize().unwrap();
2477
2478        let clean = Some("true".to_string());
2479        assert_eq!(seal_verdict(&rotating_file(&base, 0)), clean);
2480        assert_eq!(seal_verdict(&rotating_file(&base, 1)), clean);
2481    }
2482
2483    fn test_writer(dir: &TempDir) -> DiskBuffer {
2484        DiskBuffer::builder()
2485            .base_path(dir.path())
2486            .max_file_size(100_000)
2487            .max_total_size(10_000_000)
2488            .rotation_period(Duration::from_secs(60))
2489            .adaptive_rotation(AdaptiveRotationConfig::default())
2490            .build()
2491            .unwrap()
2492    }
2493
2494    #[test]
2495    fn test_rotation_period_is_fixed_without_a_config() {
2496        let dir = TempDir::new().unwrap();
2497        let mut writer = DiskBuffer::builder()
2498            .base_path(dir.path())
2499            .max_file_size(100_000)
2500            .max_total_size(10_000_000)
2501            .rotation_period(Duration::from_secs(60))
2502            .build()
2503            .unwrap();
2504        assert_eq!(writer.current_period, Duration::from_secs(60));
2505        assert_eq!(writer.drain_interval(), DEFAULT_DRAIN_INTERVAL);
2506
2507        writer.adapt_rotation_period(8_000_000, Duration::from_secs(1));
2508
2509        assert_eq!(writer.current_period, Duration::from_secs(60));
2510    }
2511
2512    #[test]
2513    fn test_adaptive_rotation_starts_at_the_floor() {
2514        let dir = TempDir::new().unwrap();
2515        let writer = test_writer(&dir);
2516        assert_eq!(writer.current_period, DEFAULT_MIN_ROTATION_PERIOD);
2517        assert_eq!(writer.drain_interval(), DEFAULT_MIN_ROTATION_PERIOD);
2518    }
2519
2520    /// A quiet segment earns a longer period, but only one step at a time, and
2521    /// the drain interval follows it up.
2522    #[test]
2523    fn test_rotation_period_grows_one_step_at_a_time() {
2524        let dir = TempDir::new().unwrap();
2525        let mut writer = test_writer(&dir);
2526
2527        writer.adapt_rotation_period(100, Duration::from_secs(60));
2528        assert_eq!(writer.current_period, DEFAULT_MIN_ROTATION_PERIOD * 2);
2529        assert_eq!(writer.drain_interval(), DEFAULT_MIN_ROTATION_PERIOD * 2);
2530
2531        writer.adapt_rotation_period(100, Duration::from_secs(60));
2532        assert_eq!(writer.current_period, DEFAULT_MIN_ROTATION_PERIOD * 4);
2533    }
2534
2535    /// Repeated growth stops at the ceiling.
2536    #[test]
2537    fn test_rotation_period_grows_to_the_ceiling() {
2538        let dir = TempDir::new().unwrap();
2539        let mut writer = test_writer(&dir);
2540        for _ in 0..10 {
2541            writer.adapt_rotation_period(100, Duration::from_secs(60));
2542        }
2543        assert_eq!(writer.current_period, Duration::from_secs(60));
2544    }
2545
2546    /// A shorter period applies in full at the next rotation.
2547    #[test]
2548    fn test_rotation_period_shrinks_in_one_step() {
2549        let dir = TempDir::new().unwrap();
2550        let mut writer = test_writer(&dir);
2551        writer.current_period = Duration::from_secs(40);
2552
2553        // The segment closed on the 80 KB target after 10s, so the estimate is
2554        // 10s: a shrink from 40s, and shrinks are not capped.
2555        writer.adapt_rotation_period(80_000, Duration::from_secs(10));
2556
2557        assert_eq!(writer.current_period, Duration::from_secs(10));
2558        assert_eq!(writer.drain_interval(), Duration::from_secs(10));
2559    }
2560
2561    #[test]
2562    fn test_rotation_period_clamps_to_floor() {
2563        let dir = TempDir::new().unwrap();
2564        let mut writer = test_writer(&dir);
2565        writer.current_period = Duration::from_secs(40);
2566        writer.adapt_rotation_period(8_000_000, Duration::from_secs(1));
2567        assert_eq!(writer.current_period, DEFAULT_MIN_ROTATION_PERIOD);
2568    }
2569
2570    /// An empty or zero-length segment carries no rate, so the estimate holds.
2571    #[test]
2572    fn test_rotation_period_ignores_unusable_samples() {
2573        let dir = TempDir::new().unwrap();
2574        let mut writer = test_writer(&dir);
2575        writer.current_period = Duration::from_secs(12);
2576
2577        writer.adapt_rotation_period(0, Duration::from_secs(10));
2578        writer.adapt_rotation_period(80_000, Duration::ZERO);
2579
2580        assert_eq!(writer.current_period, Duration::from_secs(12));
2581    }
2582
2583    /// The config's own bounds win over the writer's `rotation_period`.
2584    #[test]
2585    fn test_rotation_period_honours_the_configured_bounds() {
2586        let dir = TempDir::new().unwrap();
2587        let mut writer = DiskBuffer::builder()
2588            .base_path(dir.path())
2589            .max_file_size(100_000)
2590            .max_total_size(10_000_000)
2591            .rotation_period(Duration::from_secs(60))
2592            .adaptive_rotation(
2593                AdaptiveRotationConfig::default()
2594                    .min_period(Duration::from_secs(1))
2595                    .max_period(Duration::from_secs(4))
2596                    .target_bytes(50_000),
2597            )
2598            .build()
2599            .unwrap();
2600        assert_eq!(writer.current_period, Duration::from_secs(1));
2601        assert_eq!(writer.target_segment_bytes(), 50_000);
2602
2603        for _ in 0..10 {
2604            writer.adapt_rotation_period(100, Duration::from_secs(60));
2605        }
2606        assert_eq!(writer.current_period, Duration::from_secs(4));
2607    }
2608
2609    /// Drives the writer in 5 ms ticks, at a byte rate that fills `max_file_size`
2610    /// well inside the 60 s ceiling. Every segment should still be sealed by its
2611    /// rotation deadline.
2612    #[tokio::test(start_paused = true)]
2613    async fn test_adaptive_rotation_keeps_segments_off_the_size_path() {
2614        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2615        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2616
2617        let dir = TempDir::new().unwrap();
2618        let base = dir.path().join("trace");
2619        let max_file_size = 100_000;
2620        let mut writer = DiskBuffer::builder()
2621            .base_path(dir.path())
2622            .max_file_size(max_file_size)
2623            .max_total_size(100_000_000)
2624            .rotation_period(Duration::from_secs(60))
2625            .adaptive_rotation(AdaptiveRotationConfig::default())
2626            .build()
2627            .unwrap();
2628
2629        // 120 s of 5 ms ticks, one batch each.
2630        for _ in 0..24_000 {
2631            writer.write_encoded_batch(&test_batch()).unwrap();
2632            tokio::time::advance(Duration::from_millis(5)).await;
2633            if writer.should_drain() {
2634                writer.drained().unwrap();
2635            }
2636        }
2637        writer.finalize().unwrap();
2638
2639        assert!(
2640            writer.next_index > 2,
2641            "expected several rotations, got {}",
2642            writer.next_index
2643        );
2644        for i in 0..writer.next_index {
2645            let Ok(meta) = std::fs::metadata(rotating_file(&base, i)) else {
2646                continue;
2647            };
2648            assert!(
2649                meta.len() <= max_file_size,
2650                "segment {i} reached {} bytes, over max_file_size",
2651                meta.len()
2652            );
2653        }
2654    }
2655
2656    #[tokio::test(start_paused = true)]
2657    async fn test_adaptive_rotation_covers_memory_mode() {
2658        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
2659        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
2660
2661        let mut writer = MemoryBuffer::builder()
2662            .max_total_size(100_000_000)
2663            .max_segment_size(100_000)
2664            .rotation_period(Duration::from_secs(60))
2665            .adaptive_rotation(AdaptiveRotationConfig::default())
2666            .build()
2667            .unwrap();
2668
2669        for _ in 0..24_000 {
2670            writer.write_encoded_batch(&test_batch()).unwrap();
2671            tokio::time::advance(Duration::from_millis(5)).await;
2672            if writer.should_drain() {
2673                writer.drained().unwrap();
2674            }
2675        }
2676        writer.finalize().unwrap();
2677
2678        let seals = writer.seal_counts();
2679        assert!(
2680            seals.segments_sealed_clean > 2,
2681            "expected several rotations, got {}",
2682            seals.segments_sealed_clean
2683        );
2684        assert_eq!(
2685            seals.segments_sealed_undrained, 0,
2686            "every segment should be sealed by its rotation deadline"
2687        );
2688    }
2689
2690    /// `single_file` disables rotation, so nothing adapts it.
2691    #[test]
2692    fn test_single_file_period_never_adapts() {
2693        let dir = TempDir::new().unwrap();
2694        let mut writer = DiskBuffer::single_file(dir.path().join("trace.bin")).unwrap();
2695        writer.adapt_rotation_period(8_000_000, Duration::from_secs(1));
2696        assert_eq!(writer.current_period, Duration::MAX);
2697    }
2698
2699    #[test]
2700    fn test_clock_sync_precedes_first_data_event() {
2701        use crate::sealed::LEGACY_EPOCH_NS_FLOOR;
2702
2703        let dir = TempDir::new().unwrap();
2704        let base = dir.path().join("trace");
2705        let mut writer = DiskBuffer::builder()
2706            .base_path(dir.path())
2707            .max_file_size(100_000)
2708            .max_total_size(100_000)
2709            .build()
2710            .unwrap();
2711        writer.write_encoded_batch(&test_batch()).unwrap();
2712        writer.flush().unwrap();
2713        writer.finalize().unwrap();
2714
2715        let data = std::fs::read(rotating_file(&base, 0)).unwrap();
2716        let all = decode_all(&data);
2717
2718        // ClockSync must precede the first data event so a streaming
2719        // decoder never sees a data timestamp without an anchor.
2720        let first_data_idx = all
2721            .iter()
2722            .position(|e| matches!(e, Decoded::Data { .. }))
2723            .expect("expected at least one data event");
2724        let first_clock_sync_idx = all
2725            .iter()
2726            .position(|e| matches!(e, Decoded::ClockSync { .. }))
2727            .expect("expected a ClockSyncEvent in the file");
2728        assert!(first_clock_sync_idx < first_data_idx);
2729
2730        match &all[first_clock_sync_idx] {
2731            Decoded::ClockSync { realtime_ns, .. } => {
2732                assert!(*realtime_ns >= LEGACY_EPOCH_NS_FLOOR);
2733            }
2734            _ => unreachable!(),
2735        }
2736    }
2737
2738    #[test]
2739    fn test_segment_metadata_timestamp_is_monotonic_scale() {
2740        use crate::sealed::LEGACY_EPOCH_NS_FLOOR;
2741
2742        let dir = TempDir::new().unwrap();
2743        let base = dir.path().join("trace");
2744        let mut writer = DiskBuffer::builder()
2745            .base_path(dir.path())
2746            .max_file_size(100_000)
2747            .max_total_size(100_000)
2748            .build()
2749            .unwrap();
2750        writer.write_encoded_batch(&test_batch()).unwrap();
2751        writer.flush().unwrap();
2752        writer.finalize().unwrap();
2753
2754        let data = std::fs::read(rotating_file(&base, 0)).unwrap();
2755        let all = decode_all(&data);
2756
2757        // SegmentMetadata.timestamp_ns should remain monotonic-scale,
2758        // not epoch wall-clock.
2759        let seg_ts = all
2760            .iter()
2761            .find_map(|e| match e {
2762                Decoded::SegmentMetadata { timestamp_ns, .. } => Some(*timestamp_ns),
2763                _ => None,
2764            })
2765            .expect("SegmentMetadata");
2766        assert!(
2767            seg_ts < LEGACY_EPOCH_NS_FLOOR,
2768            "SegmentMetadata.timestamp_nanos ({seg_ts}) should be monotonic-scale"
2769        );
2770    }
2771
2772    #[test]
2773    fn test_clock_sync_written_in_every_rotated_file() {
2774        let dir = TempDir::new().unwrap();
2775        let one_event = single_event_file_size();
2776        let mut writer = DiskBuffer::builder()
2777            .base_path(dir.path())
2778            .max_file_size(one_event)
2779            .max_total_size(100_000)
2780            .build()
2781            .unwrap();
2782
2783        for _ in 0..5 {
2784            writer.write_encoded_batch(&test_batch()).unwrap();
2785        }
2786        writer.flush().unwrap();
2787        writer.finalize().unwrap();
2788
2789        let mut files: Vec<_> = std::fs::read_dir(dir.path())
2790            .unwrap()
2791            .filter_map(|e| e.ok())
2792            .map(|e| e.path())
2793            .filter(|p| p.extension().is_some_and(|ext| ext == "bin"))
2794            .collect();
2795        files.sort();
2796        assert!(files.len() >= 2, "expected at least 2 files from rotation");
2797
2798        for file in &files {
2799            let all = decode_all(&std::fs::read(file).unwrap());
2800            let has_clock_sync = all.iter().any(|e| matches!(e, Decoded::ClockSync { .. }));
2801            assert!(
2802                has_clock_sync,
2803                "{}: expected ClockSyncEvent",
2804                file.display()
2805            );
2806        }
2807    }
2808
2809    /// A hand-built legacy-shaped buffer (SegmentMetadata + data event,
2810    /// no ClockSyncEvent) must still round-trip through the decoder.
2811    #[test]
2812    fn test_legacy_trace_without_clock_sync_still_decodes() {
2813        let mut enc = Encoder::new_to(Vec::new()).unwrap();
2814        enc.write(&SegmentMetadataEvent {
2815            timestamp_ns: 1,
2816            entries: vec![("k".into(), "v".into())],
2817        })
2818        .unwrap();
2819        enc.write(&TestEvent {
2820            timestamp_ns: 1000,
2821            value: 0,
2822        })
2823        .unwrap();
2824        let buf = enc.into_inner();
2825
2826        let all = decode_all(&buf);
2827        assert!(
2828            all.iter().any(|e| matches!(e, Decoded::Data { .. })),
2829            "expected data event to decode"
2830        );
2831        assert!(
2832            !all.iter().any(|e| matches!(e, Decoded::ClockSync { .. })),
2833            "legacy trace must not contain ClockSync"
2834        );
2835    }
2836
2837    #[test]
2838    fn test_clock_sync_offset_recovers_wall_clock_for_recent_event() {
2839        use std::time::{SystemTime, UNIX_EPOCH};
2840
2841        let dir = TempDir::new().unwrap();
2842        let base = dir.path().join("trace");
2843        let mut writer = DiskBuffer::builder()
2844            .base_path(dir.path())
2845            .max_file_size(100_000)
2846            .max_total_size(100_000)
2847            .build()
2848            .unwrap();
2849
2850        // Use a real monotonic reading so reconstruction lands near now.
2851        let park_ts = crate::clock::clock_monotonic_ns();
2852        let mut enc = Encoder::new_to(Vec::new()).unwrap();
2853        enc.write(&TestEvent {
2854            timestamp_ns: park_ts,
2855            value: 0,
2856        })
2857        .unwrap();
2858        writer
2859            .write_encoded_batch(&Batch::new(enc.into_inner(), 1))
2860            .unwrap();
2861        writer.flush().unwrap();
2862        writer.finalize().unwrap();
2863
2864        let all = decode_all(&std::fs::read(rotating_file(&base, 0)).unwrap());
2865
2866        let (sync_mono, sync_real) = all
2867            .iter()
2868            .find_map(|e| match e {
2869                Decoded::ClockSync {
2870                    timestamp_ns,
2871                    realtime_ns,
2872                } => Some((*timestamp_ns, *realtime_ns)),
2873                _ => None,
2874            })
2875            .expect("ClockSync");
2876        let park_from_file = all
2877            .iter()
2878            .find_map(|e| match e {
2879                Decoded::Data { timestamp_ns } => Some(*timestamp_ns),
2880                _ => None,
2881            })
2882            .expect("data event");
2883
2884        let offset = sync_real as i128 - sync_mono as i128;
2885        let reconstructed_wall_ns = park_from_file as i128 + offset;
2886        let now_ns = SystemTime::now()
2887            .duration_since(UNIX_EPOCH)
2888            .unwrap()
2889            .as_nanos() as i128;
2890        let diff = (reconstructed_wall_ns - now_ns).abs();
2891        assert!(
2892            diff < 5_000_000_000,
2893            "reconstructed wall clock {reconstructed_wall_ns} diverges from now {now_ns} by {diff}ns"
2894        );
2895    }
2896
2897    /// S3-style metadata set via `update_segment_metadata` before any events
2898    /// are written must appear in the segment's SegmentMetadata event.
2899    #[test]
2900    fn test_update_segment_metadata_appears_in_trace() {
2901        let dir = TempDir::new().unwrap();
2902        let base = dir.path().join("trace");
2903        let mut writer = DiskBuffer::builder()
2904            .base_path(dir.path())
2905            .max_file_size(100_000)
2906            .max_total_size(100_000)
2907            .build()
2908            .unwrap();
2909
2910        // Simulate the recorder builder setting S3 metadata
2911        writer.update_segment_metadata(vec![
2912            ("bucket".into(), "my-bucket".into()),
2913            ("service_name".into(), "my-svc".into()),
2914        ]);
2915
2916        writer.write_encoded_batch(&test_batch()).unwrap();
2917        writer.flush().unwrap();
2918        writer.finalize().unwrap();
2919
2920        let all = decode_all(&std::fs::read(rotating_file(&base, 0)).unwrap());
2921        let metadata: Vec<_> = all
2922            .iter()
2923            .filter_map(|e| match e {
2924                Decoded::SegmentMetadata { entries, .. } => Some(entries.clone()),
2925                _ => None,
2926            })
2927            .collect();
2928        assert!(!metadata.is_empty(), "expected SegmentMetadata event");
2929        assert!(
2930            metadata.last().unwrap().get("bucket").map(String::as_str) == Some("my-bucket"),
2931            "S3 metadata should be in segment"
2932        );
2933        assert!(
2934            metadata
2935                .last()
2936                .unwrap()
2937                .get("service_name")
2938                .map(String::as_str)
2939                == Some("my-svc"),
2940            "S3 metadata should be in segment"
2941        );
2942    }
2943
2944    /// Simulates the flush loop pattern: S3 metadata is set once, then
2945    /// runtime entries are merged repeatedly. S3 metadata must survive.
2946    #[test]
2947    fn test_merge_preserves_s3_metadata_across_runtime_updates() {
2948        let dir = TempDir::new().unwrap();
2949        let one_event = single_event_file_size();
2950        let mut writer = DiskBuffer::builder()
2951            .base_path(dir.path())
2952            .max_file_size(one_event)
2953            .max_total_size(100_000)
2954            .build()
2955            .unwrap();
2956
2957        // Step 1: S3 metadata set (like the recorder builder)
2958        writer.update_segment_metadata(vec![
2959            ("bucket".into(), "my-bucket".into()),
2960            ("service_name".into(), "my-svc".into()),
2961        ]);
2962
2963        // Step 2: flush loop merges only runtime entries — S3 metadata
2964        // set in step 1 must be preserved by the merge logic.
2965        writer.update_segment_metadata(vec![("runtime.main".into(), "0,1".into())]);
2966
2967        // Write enough to trigger rotation
2968        for _ in 0..4 {
2969            writer.write_encoded_batch(&test_batch()).unwrap();
2970        }
2971        writer.flush().unwrap();
2972        writer.finalize().unwrap();
2973
2974        let mut files: Vec<_> = std::fs::read_dir(dir.path())
2975            .unwrap()
2976            .filter_map(|e| e.ok())
2977            .map(|e| e.path())
2978            .filter(|p| p.extension().is_some_and(|ext| ext == "bin"))
2979            .collect();
2980        files.sort();
2981        assert!(files.len() >= 2, "expected rotation");
2982
2983        // Rotated segments should contain both S3 and runtime metadata
2984        for file in &files[1..] {
2985            let all = decode_all(&std::fs::read(file).unwrap());
2986            let meta: Vec<_> = all
2987                .iter()
2988                .filter_map(|e| match e {
2989                    Decoded::SegmentMetadata { entries, .. } => Some(entries.clone()),
2990                    _ => None,
2991                })
2992                .collect();
2993            let last = meta.last().expect("expected SegmentMetadata");
2994            assert!(
2995                last.get("bucket").map(String::as_str) == Some("my-bucket"),
2996                "{}: S3 metadata lost after merge",
2997                file.display()
2998            );
2999            assert!(
3000                last.get("runtime.main").map(String::as_str) == Some("0,1"),
3001                "{}: runtime metadata missing",
3002                file.display()
3003            );
3004        }
3005    }
3006
3007    /// Repeated calls to `update_segment_metadata` with identical entries
3008    /// should not set `need_metadata`, avoiding redundant writes.
3009    #[test]
3010    fn test_update_segment_metadata_no_op_when_unchanged() {
3011        let dir = TempDir::new().unwrap();
3012        let base = dir.path().join("trace");
3013        let mut writer = DiskBuffer::builder()
3014            .base_path(dir.path())
3015            .max_file_size(100_000)
3016            .max_total_size(100_000)
3017            .build()
3018            .unwrap();
3019
3020        let entries = vec![("k".into(), "v".into())];
3021        writer.update_segment_metadata(entries.clone());
3022        // First batch writes metadata
3023        writer.write_encoded_batch(&test_batch()).unwrap();
3024
3025        // Same entries again — should be a no-op
3026        writer.update_segment_metadata(entries.clone());
3027        // Second batch should NOT write another metadata event
3028        writer.write_encoded_batch(&test_batch()).unwrap();
3029        writer.flush().unwrap();
3030        writer.finalize().unwrap();
3031
3032        let all = decode_all(&std::fs::read(rotating_file(&base, 0)).unwrap());
3033        let metadata_count = all
3034            .iter()
3035            .filter(|e| matches!(e, Decoded::SegmentMetadata { .. }))
3036            .count();
3037
3038        assert_eq!(
3039            metadata_count, 2,
3040            "identical update_segment_metadata should not trigger another write"
3041        );
3042    }
3043
3044    /// The crates.io version of the writer's crate is embedded in every
3045    /// segment's metadata under `dial9.dial9-tokio-telemetry.version`. Regression test for
3046    /// https://github.com/dial9-rs/dial9/issues/423.
3047    #[test]
3048    fn test_dial9_version_in_segment_metadata() {
3049        let dir = TempDir::new().unwrap();
3050        let path = dir.path().join("trace.bin");
3051        let mut writer = DiskBuffer::single_file(&path).unwrap();
3052        writer.write_encoded_batch(&test_batch()).unwrap();
3053        writer.flush().unwrap();
3054        writer.finalize().unwrap();
3055
3056        let sealed = dir.path().join("trace.0.bin");
3057        let all = decode_all(&std::fs::read(&sealed).unwrap());
3058        let version_value = all.iter().find_map(|e| match e {
3059            Decoded::SegmentMetadata { entries, .. } => entries.get(DIAL9_VERSION_KEY).cloned(),
3060            _ => None,
3061        });
3062        assert_eq!(
3063            version_value.as_deref(),
3064            Some(env!("CARGO_PKG_VERSION")),
3065            "expected dial9.dial9-tokio-telemetry.version entry matching CARGO_PKG_VERSION"
3066        );
3067    }
3068
3069    /// The process's available logical CPU capacity is embedded in every
3070    /// segment's metadata when the platform can report it.
3071    #[test]
3072    fn test_available_parallelism_in_segment_metadata() {
3073        let expected = std::thread::available_parallelism().map(|n| n.get().to_string());
3074        let dir = TempDir::new().unwrap();
3075        let path = dir.path().join("trace.bin");
3076        let mut writer = DiskBuffer::single_file(&path).unwrap();
3077        writer.write_encoded_batch(&test_batch()).unwrap();
3078        writer.flush().unwrap();
3079        writer.finalize().unwrap();
3080
3081        let sealed = dir.path().join("trace.0.bin");
3082        let all = decode_all(&std::fs::read(&sealed).unwrap());
3083        let value = all.iter().find_map(|e| match e {
3084            Decoded::SegmentMetadata { entries, .. } => {
3085                entries.get(PROCESS_AVAILABLE_PARALLELISM_KEY).cloned()
3086            }
3087            _ => None,
3088        });
3089        match expected {
3090            Ok(expected) => assert_eq!(
3091                value.as_deref(),
3092                Some(expected.as_str()),
3093                "expected process.available_parallelism entry matching std::thread::available_parallelism()"
3094            ),
3095            Err(_) => assert!(
3096                value.is_none(),
3097                "process.available_parallelism should be omitted when available_parallelism() fails"
3098            ),
3099        }
3100    }
3101
3102    /// User-supplied `dial9.dial9-tokio-telemetry.version` entries win over the built-in default,
3103    /// both at builder time and via `update_segment_metadata`.
3104    #[test]
3105    fn test_dial9_version_user_override_wins() {
3106        let dir = TempDir::new().unwrap();
3107        let base = dir.path().join("trace");
3108        let mut writer = DiskBuffer::builder()
3109            .base_path(dir.path())
3110            .max_file_size(100_000)
3111            .max_total_size(100_000)
3112            .segment_metadata(vec![(DIAL9_VERSION_KEY.into(), "builder-override".into())])
3113            .build()
3114            .unwrap();
3115        writer.write_encoded_batch(&test_batch()).unwrap();
3116        writer.flush().unwrap();
3117        // Rotate and then runtime-override on the next segment.
3118        writer.rotate(SealKind::Drained).unwrap();
3119        writer.update_segment_metadata(vec![(DIAL9_VERSION_KEY.into(), "runtime-override".into())]);
3120        writer.write_encoded_batch(&test_batch()).unwrap();
3121        writer.flush().unwrap();
3122        writer.finalize().unwrap();
3123
3124        let read_version = |idx: u32| -> String {
3125            let all = decode_all(&std::fs::read(rotating_file(&base, idx)).unwrap());
3126            all.iter()
3127                .find_map(|e| match e {
3128                    Decoded::SegmentMetadata { entries, .. } => {
3129                        entries.get(DIAL9_VERSION_KEY).cloned()
3130                    }
3131                    _ => None,
3132                })
3133                .expect("expected dial9.dial9-tokio-telemetry.version entry")
3134        };
3135        assert_eq!(read_version(0), "builder-override");
3136        assert_eq!(read_version(1), "runtime-override");
3137    }
3138
3139    /// Regression test for https://github.com/dial9-rs/dial9/issues/386
3140    ///
3141    /// If the `.active` file is removed externally (e.g. by an operator,
3142    /// log-rotation tool, or container teardown) the flush loop calls
3143    /// `drained()` → `rotate()` → `fs::rename(.active, .bin)` which fails
3144    /// with `NotFound`. Without recovery, `next_drain_time` is never
3145    /// advanced, so `should_drain()` returns true on every subsequent
3146    /// 5ms tick and the flush thread busy-loops.
3147    ///
3148    /// `drained()` must recover by abandoning the missing segment, opening a
3149    /// fresh one, and advancing the drain/rotation timers.
3150    #[tokio::test(start_paused = true)]
3151    async fn test_drained_recovers_when_active_file_deleted() {
3152        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
3153        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
3154
3155        let dir = TempDir::new().unwrap();
3156        let mut writer = DiskBuffer::builder()
3157            .base_path(dir.path())
3158            .max_file_size(u64::MAX)
3159            .max_total_size(100_000)
3160            .rotation_period(Duration::from_secs(60))
3161            .build()
3162            .unwrap();
3163
3164        writer.write_encoded_batch(&test_batch()).unwrap();
3165        writer.flush().unwrap();
3166
3167        // Simulate external deletion of the .active file.
3168        let active_path = writer.current_active_path().to_owned();
3169        assert!(active_path.exists());
3170        std::fs::remove_file(&active_path).unwrap();
3171
3172        // Cross the rotation boundary so drained() will try to rotate.
3173        tokio::time::advance(Duration::from_secs(61)).await;
3174
3175        assert!(writer.should_drain(), "should_drain should fire");
3176
3177        // drained() must succeed despite the missing .active file. Returning
3178        // an error here is what causes the flush thread to busy-loop because
3179        // the timers are never advanced.
3180        writer
3181            .drained()
3182            .expect("drained() must recover from missing .active file");
3183
3184        // After recovery, should_drain() must return false — otherwise the
3185        // flush thread would spin calling drained() every 5ms.
3186        assert!(
3187            !writer.should_drain(),
3188            "should_drain must return false after recovery (otherwise flush loop spins)"
3189        );
3190
3191        // The writer must still be usable: a fresh active file exists and
3192        // subsequent writes succeed.
3193        writer.write_encoded_batch(&test_batch()).unwrap();
3194        writer.flush().unwrap();
3195        assert!(
3196            writer.current_active_path().exists(),
3197            "writer must have a fresh active file after recovery"
3198        );
3199
3200        writer.finalize().unwrap();
3201    }
3202
3203    /// Companion to `test_drained_recovers_when_active_file_deleted` covering
3204    /// the more realistic case where the entire trace directory has been
3205    /// removed (e.g. `rm -rf /var/log/dial9/`). Both the rename AND the
3206    /// `File::create` for the new segment fail with `NotFound`. `drained()`
3207    /// must still advance timers so `should_drain()` stops firing — the
3208    /// writer can transition to `Finished`, but the flush loop must NOT
3209    /// busy-spin.
3210    #[tokio::test(start_paused = true)]
3211    async fn test_drained_recovers_when_parent_dir_deleted() {
3212        use metrique_timesource::{TimeSource, tokio::set_time_source_for_current_runtime};
3213        let _guard = set_time_source_for_current_runtime(TimeSource::tokio(std::time::UNIX_EPOCH));
3214
3215        let dir = TempDir::new().unwrap();
3216        let trace_dir = dir.path().join("traces");
3217        std::fs::create_dir_all(&trace_dir).unwrap();
3218        let mut writer = DiskBuffer::builder()
3219            .base_path(&trace_dir)
3220            .max_file_size(u64::MAX)
3221            .max_total_size(100_000)
3222            .rotation_period(Duration::from_secs(60))
3223            .build()
3224            .unwrap();
3225
3226        writer.write_encoded_batch(&test_batch()).unwrap();
3227        writer.flush().unwrap();
3228
3229        std::fs::remove_dir_all(&trace_dir).unwrap();
3230        assert!(!writer.current_active_path().exists());
3231
3232        tokio::time::advance(Duration::from_secs(61)).await;
3233        assert!(writer.should_drain());
3234
3235        // `drained()` may surface the underlying error, but the critical
3236        // invariant is that `should_drain()` must NOT fire on the next tick —
3237        // otherwise the flush thread busy-loops.
3238        let _ = writer.drained();
3239        assert!(
3240            !writer.should_drain(),
3241            "should_drain must return false after a failed rotation \
3242             (otherwise the flush loop spins on every 5ms tick)"
3243        );
3244
3245        // Subsequent drained() calls must not re-fire either.
3246        tokio::time::advance(Duration::from_millis(5)).await;
3247        let _ = writer.drained();
3248        assert!(!writer.should_drain());
3249    }
3250
3251    /// Across a process restart, retained `.bin`/`.bin.gz` artifacts from the
3252    /// previous lifetime must count toward `max_total_size`. Without this, a
3253    /// crash-restart loop grows the trace directory unbounded.
3254    #[test]
3255    fn test_restart_seeds_closed_files_and_evicts() {
3256        let dir = TempDir::new().unwrap();
3257        let base = dir.path().join("trace");
3258        // Lifetime 1: write a few sealed segments.
3259        let one_event = single_event_file_size();
3260        {
3261            let mut w = DiskBuffer::builder()
3262                .base_path(dir.path())
3263                .max_file_size(one_event)
3264                .max_total_size(100_000)
3265                .build()
3266                .unwrap();
3267            for _ in 0..4 {
3268                w.write_encoded_batch(&test_batch()).unwrap();
3269            }
3270            w.finalize().unwrap();
3271        }
3272        let bin_count_before = (0..20)
3273            .filter(|i| std::path::Path::new(&rotating_file(&base, *i)).exists())
3274            .count();
3275        assert!(
3276            bin_count_before >= 2,
3277            "lifetime 1 should leave multiple sealed segments"
3278        );
3279
3280        // Lifetime 2: shrink the budget so existing artifacts must be evicted.
3281        let new_budget = one_event + 1; // fits ~1 retained segment + the new active one
3282        let writer = DiskBuffer::builder()
3283            .base_path(dir.path())
3284            .max_file_size(one_event)
3285            .max_total_size(new_budget)
3286            .build()
3287            .unwrap();
3288        // Discovery + immediate evict_oldest should have shed older segments.
3289        assert!(
3290            total_disk_usage(dir.path()) <= new_budget,
3291            "disk usage exceeds shrunk budget after restart: {}",
3292            total_disk_usage(dir.path())
3293        );
3294        // Next active index must not collide with retained segments.
3295        let next_active_path = writer.current_active_path();
3296        assert!(next_active_path.exists());
3297        assert!(
3298            next_active_path
3299                .to_str()
3300                .is_some_and(|s| s.ends_with(".bin.active"))
3301        );
3302    }
3303
3304    /// Stale `.active` files from a dead writer can't be processed by the
3305    /// worker — they must be cleaned up on startup so the next writer doesn't
3306    /// trip over orphaned indices.
3307    #[test]
3308    fn test_restart_discards_stale_active_files() {
3309        let dir = TempDir::new().unwrap();
3310        // Simulate an orphan from a previous, crashed writer.
3311        let orphan = dir.path().join("trace.99.bin.active");
3312        std::fs::write(&orphan, b"orphaned").unwrap();
3313
3314        let _w = DiskBuffer::builder()
3315            .base_path(dir.path())
3316            .max_file_size(1024)
3317            .max_total_size(100_000)
3318            .build()
3319            .unwrap();
3320        assert!(
3321            !orphan.exists(),
3322            "stale .active should be discarded on construction"
3323        );
3324    }
3325
3326    /// `.bin.gz` write-back siblings must count toward the eviction budget so
3327    /// post-processing doesn't push retention past the cap.
3328    #[test]
3329    fn test_restart_counts_gz_siblings_toward_budget() {
3330        let dir = TempDir::new().unwrap();
3331        // Simulate a previous lifetime where WriteBack produced a .bin.gz.
3332        let bin = dir.path().join("trace.0.bin");
3333        let gz = dir.path().join("trace.0.bin.gz");
3334        std::fs::write(&bin, vec![0u8; 4096]).unwrap();
3335        std::fs::write(&gz, vec![0u8; 1024]).unwrap();
3336
3337        // Budget too small for both. Restart must evict the whole family.
3338        let _w = DiskBuffer::builder()
3339            .base_path(dir.path())
3340            .max_file_size(100_000)
3341            .max_total_size(100)
3342            .build()
3343            .unwrap();
3344        assert!(!bin.exists(), ".bin should be evicted under restart budget");
3345        assert!(!gz.exists(), ".bin.gz must be evicted with its .bin family");
3346    }
3347
3348    /// finalize() must run eviction so the final sealed segment counts toward
3349    /// the budget. Without it, finalize can leave the directory over cap.
3350    #[test]
3351    fn test_finalize_evicts_to_budget() {
3352        let dir = TempDir::new().unwrap();
3353        let one_event = single_event_file_size();
3354        let max_total_size = one_event * 2;
3355        let mut writer = DiskBuffer::builder()
3356            .base_path(dir.path())
3357            .max_file_size(one_event)
3358            .max_total_size(max_total_size)
3359            .build()
3360            .unwrap();
3361
3362        for _ in 0..10 {
3363            writer.write_encoded_batch(&test_batch()).unwrap();
3364        }
3365        writer.finalize().unwrap();
3366
3367        assert!(
3368            total_disk_usage(dir.path()) <= max_total_size,
3369            "finalize must leave disk usage within budget"
3370        );
3371    }
3372
3373    #[test]
3374    fn in_memory_builder_wires_custom_options() {
3375        let writer = MemoryBuffer::builder()
3376            .max_total_size(8 * 1024 * 1024)
3377            .max_segment_size(64 * 1024)
3378            .rotation_period(Duration::from_secs(30))
3379            .segment_metadata(vec![("svc".into(), "test".into())])
3380            .build()
3381            .unwrap();
3382        assert_eq!(writer.max_file_size, 64 * 1024);
3383        assert_eq!(writer.rotation_period, Duration::from_secs(30));
3384        assert!(
3385            writer
3386                .segment_metadata
3387                .entries
3388                .iter()
3389                .any(|(k, v)| k == "svc" && v == "test")
3390        );
3391    }
3392
3393    #[test]
3394    fn in_memory_rejects_zero_total_size() {
3395        let err = MemoryBuffer::new(0).unwrap_err();
3396        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
3397    }
3398
3399    #[test]
3400    fn in_memory_builder_enforces_3x_segment_min_total_size() {
3401        let seg: u64 = 2048;
3402        // Below the boundary: rejected (no room for even one ring slot).
3403        let err = MemoryBuffer::builder()
3404            .max_total_size(3 * seg - 1)
3405            .max_segment_size(seg)
3406            .build()
3407            .unwrap_err();
3408        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
3409        // At boundary: accepted (1 active + 1 in-flight + 1 ring slot).
3410        MemoryBuffer::builder()
3411            .max_total_size(3 * seg)
3412            .max_segment_size(seg)
3413            .build()
3414            .expect("3× segment must be accepted");
3415    }
3416
3417    /// End-to-end writer -> worker tests: drive a memory writer through the
3418    /// real worker pipeline and assert every written event reaches a processor.
3419    #[cfg(feature = "pipeline")]
3420    mod mem_e2e_tests {
3421        use super::*;
3422        use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
3423        use crate::worker::WorkerLoop;
3424        use std::future::Future;
3425        use std::pin::Pin;
3426        use std::sync::{Arc, Mutex};
3427        use std::time::Duration;
3428
3429        /// Captures each processed segment's payload bytes.
3430        struct CapturingProcessor {
3431            segments: Arc<Mutex<Vec<Vec<u8>>>>,
3432        }
3433
3434        impl CapturingProcessor {
3435            fn new() -> (Self, Arc<Mutex<Vec<Vec<u8>>>>) {
3436                let segments = Arc::new(Mutex::new(Vec::new()));
3437                (
3438                    Self {
3439                        segments: segments.clone(),
3440                    },
3441                    segments,
3442                )
3443            }
3444        }
3445
3446        impl SegmentProcessor for CapturingProcessor {
3447            fn name(&self) -> &'static str {
3448                "Capture"
3449            }
3450            fn process(
3451                &mut self,
3452                data: SegmentData,
3453            ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
3454            {
3455                self.segments
3456                    .lock()
3457                    .unwrap()
3458                    .push(data.payload().clone().into_vec());
3459                Box::pin(async move { Ok(data) })
3460            }
3461        }
3462
3463        /// Exercises the full seam: write -> Fs::Mem seal -> ring -> finalize
3464        /// (mark_writer_done) -> WorkerLoop::run drain-to-empty -> processor.
3465        async fn run_mem_e2e(mut writer: MemoryBuffer, events: usize) -> Vec<Vec<u8>> {
3466            let fs = writer.fs_handle().expect("memory writer exposes its Fs");
3467            for _ in 0..events {
3468                writer.write_encoded_batch(&test_batch()).unwrap();
3469            }
3470            // Seals the active segment onto the ring and signals writer_done.
3471            writer.finalize().unwrap();
3472
3473            let (capture, captured) = CapturingProcessor::new();
3474            // stop is never cancelled: the loop exits via writer_done only.
3475            let stop = tokio_util::sync::CancellationToken::new();
3476            let mut worker = WorkerLoop::new(
3477                fs,
3478                Duration::from_millis(5),
3479                vec![Box::new(capture)],
3480                stop,
3481                metrique_writer::sink::DevNullSink::boxed(),
3482                None,
3483            )
3484            .await
3485            .expect("initialize worker");
3486            worker.run().await;
3487
3488            let segments = captured.lock().unwrap();
3489            segments.clone()
3490        }
3491
3492        /// Count decoded payload events across `segments`, dropping the
3493        /// per-segment metadata/clock-sync framing the writer emits.
3494        fn count_payload_events(segments: &[Vec<u8>]) -> usize {
3495            segments
3496                .iter()
3497                .flat_map(|s| decode_all(s))
3498                .filter(|e| matches!(e, Decoded::Data { .. }))
3499                .count()
3500        }
3501
3502        #[tokio::test]
3503        async fn mem_writer_e2e_delivers_all_events() {
3504            const EVENTS: usize = 25;
3505
3506            let segments = run_mem_e2e(MemoryBuffer::new(1 << 20).unwrap(), EVENTS).await;
3507
3508            assert!(!segments.is_empty(), "worker captured no segments");
3509            assert_eq!(
3510                count_payload_events(&segments),
3511                EVENTS,
3512                "every written event must reach the processor"
3513            );
3514        }
3515
3516        /// Same, but a tiny `max_segment_size` forces several rotations so the
3517        /// worker delivers multiple sealed segments.
3518        #[tokio::test]
3519        async fn mem_writer_e2e_delivers_all_events_across_rotations() {
3520            const EVENTS: usize = 60;
3521
3522            // Huge ring (nothing evicts) + tiny segments (rotate every few batches).
3523            let writer = MemoryBuffer::builder()
3524                .max_total_size(16 * 1024 * 1024)
3525                .max_segment_size(256)
3526                .build()
3527                .unwrap();
3528            let segments = run_mem_e2e(writer, EVENTS).await;
3529
3530            assert!(
3531                segments.len() >= 2,
3532                "tiny segments must force rotation, got {} segment(s)",
3533                segments.len()
3534            );
3535            assert_eq!(
3536                count_payload_events(&segments),
3537                EVENTS,
3538                "every event across all rotated segments must reach the processor"
3539            );
3540        }
3541    }
3542}