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
23pub trait BufferMode: mode_sealed::Sealed + Send + 'static {
26 const IS_DISK: bool;
28}
29
30#[derive(Debug)]
32#[non_exhaustive]
33pub struct Disk;
34#[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
48pub type DiskBuffer = SegmentWriter<Disk>;
50pub type MemoryBuffer = SegmentWriter<Memory>;
52
53const DIAL9_VERSION_KEY: &str = "dial9.dial9-tokio-telemetry.version";
57
58const DIAL9_VERSION_VALUE: &str = env!("CARGO_PKG_VERSION");
60
61const PROCESS_AVAILABLE_PARALLELISM_KEY: &str = "process.available_parallelism";
64
65const SEGMENT_COMPLETE_KEY: &str = "segment.complete";
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum SealKind {
74 Drained,
76 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 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 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
145const DEFAULT_ROTATION_PERIOD: Duration = Duration::from_secs(60);
147
148const DEFAULT_DRAIN_INTERVAL: Duration = Duration::from_secs(30);
150
151const DEFAULT_MIN_ROTATION_PERIOD: Duration = Duration::from_secs(5);
153
154const ROTATION_TARGET_PERCENT: u64 = 80;
157
158const ROTATION_GROWTH_FACTOR: u32 = 2;
160
161const SEGMENT_STEM: &str = "trace";
164
165const BYTES_PER_MIB: u64 = 1024 * 1024;
166
167const MAX_FILE_SIZE_CAP: u64 = 100 * BYTES_PER_MIB;
171
172fn derive_max_file_size(max_total_size: u64) -> u64 {
176 (max_total_size / 4).min(MAX_FILE_SIZE_CAP)
177}
178
179#[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 pub fn min_period(mut self, period: Duration) -> Self {
206 self.min_period = period;
207 self
208 }
209
210 pub fn max_period(mut self, period: Duration) -> Self {
212 self.max_period = Some(period);
213 self
214 }
215
216 pub fn target_bytes(mut self, bytes: u64) -> Self {
219 self.target_bytes = Some(bytes);
220 self
221 }
222}
223
224pub struct SegmentWriter<Mode: BufferMode = Disk> {
254 dir: PathBuf,
256 stem: String,
258 max_file_size: u64,
259 max_total_size: u64,
260 rotation_period: Duration,
263 next_rotation_time: Option<Instant>,
266 closed_files: VecDeque<(SegmentRef, u64)>,
269 active_path: PathBuf,
272 state: WriterState,
273 next_index: u32,
274 segment_metadata: SegmentMetadata,
277 dropped_events: usize,
279 seals: crate::metrics::SealCounts,
281 has_real_events: bool,
284 adaptive_rotation: Option<AdaptiveRotationConfig>,
287 current_period: Duration,
290 segment_started_at: Instant,
292 prior_seal_kind: SealKind,
294 next_drain_time: Instant,
296 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#[allow(clippy::large_enum_variant)]
317enum WriterState {
318 Active {
320 writer: RawEncoder<BufWriter<ActiveHandle>>,
321 need_metadata: bool,
322 },
323
324 Finished,
326}
327
328#[bon::bon]
329impl SegmentWriter<Disk> {
330 #[builder(builder_type = DiskBufferBuilder, finish_fn = build)]
335 pub fn builder(
336 base_path: impl Into<PathBuf>,
337 max_file_size: Option<u64>,
340 max_total_size: u64,
341 rotation_period: Option<Duration>,
345 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 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 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 writer.evict_oldest()?;
422 Ok(writer)
423 }
424
425 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 pub fn single_file(path: impl Into<PathBuf>) -> std::io::Result<Self> {
441 let path = path.into();
442 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
488fn 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 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(builder_type = MemoryBufferBuilder, finish_fn = build)]
515 pub fn builder(
516 max_total_size: u64,
517 max_segment_size: Option<u64>,
519 rotation_period: Option<Duration>,
521 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 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 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 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 pub fn boot_id(&self) -> Option<&str> {
620 self.boot_id.as_deref()
621 }
622
623 pub fn trace_dir(&self) -> &Path {
628 &self.dir
629 }
630
631 pub fn trace_stem(&self) -> &str {
633 &self.stem
634 }
635
636 pub fn current_active_path(&self) -> &Path {
638 &self.active_path
639 }
640
641 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 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 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 fn active_path(dir: &Path, stem: &str, index: u32) -> PathBuf {
715 dir.join(format!("{stem}.{index}.bin.active"))
716 }
717
718 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 self.write_seal_metadata(kind);
731 self.prior_seal_kind = kind;
732 self.count_seal(kind);
733
734 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 let WriterState::Active {
749 writer: mut raw, ..
750 } = std::mem::replace(&mut self.state, WriterState::Finished)
751 else {
752 return Ok(());
753 };
754
755 let _ = raw.flush();
758 let closed_size = raw.bytes_written();
759 let current_index = self.next_index - 1;
760
761 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 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 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 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 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 self.max_file_size / 100 * ROTATION_TARGET_PERCENT
834 }
835
836 fn drain_interval(&self) -> Duration {
838 self.current_period.min(DEFAULT_DRAIN_INTERVAL)
839 }
840
841 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 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 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 self.total_size() > self.max_total_size {
888 self.state = WriterState::Finished;
889 }
890 Ok(())
891 }
892
893 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 #[cfg(feature = "pipeline")]
910 pub(crate) fn fs_handle(&self) -> Option<Arc<Fs>> {
911 Some(Arc::clone(&self.fs))
912 }
913
914 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 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 self.next_drain_time = now + self.drain_interval();
975 Ok(false)
976 }
977
978 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 let _ = self.flush();
992
993 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 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 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 crate::test_util_pub! {
1059 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 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 #[derive(TraceEvent)]
1112 #[traceevent(wire_slot)]
1113 struct TestEvent {
1114 #[traceevent(timestamp)]
1115 timestamp_ns: u64,
1116 value: u64,
1117 }
1118
1119 #[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 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 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 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 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 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 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 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 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 assert!(total_disk_usage(dir.path()) <= max_total_size);
1401
1402 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 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 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 #[test]
1452 fn test_writer_stops_on_tiny_overshoot_after_eviction() {
1453 let dir = TempDir::new().unwrap();
1454 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 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 assert!(
1505 std::path::Path::new(&rotating_file(&base, 0)).exists(),
1506 "File 0 should exist"
1507 );
1508 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 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 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() } 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 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 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 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 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 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 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 writer.write_encoded_batch(&test_batch()).unwrap();
1695 writer.write_encoded_batch(&test_batch()).unwrap();
1696 writer.flush().unwrap();
1697
1698 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 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 writer.write_encoded_batch(&test_batch()).unwrap();
1751 assert!(dir.path().join("trace.0.bin").exists());
1753 assert!(dir.path().join("trace.1.bin.active").exists());
1754
1755 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 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 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 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 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 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 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 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 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 #[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 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 writer.write_encoded_batch(&test_batch()).unwrap();
2003 writer.write_encoded_batch(&test_batch()).unwrap();
2004 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 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 assert!(!seg0_gz.exists(), "trace.0.bin.gz should have been evicted");
2022 }
2023
2024 #[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 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 assert!(
2053 writer.total_size() > max_total_size,
2054 "single segment ({}) should exceed budget ({max_total_size})",
2055 writer.total_size()
2056 );
2057
2058 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 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 #[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 tokio::time::advance(Duration::from_secs(61)).await;
2101
2102 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 #[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 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 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 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 tokio::time::advance(Duration::from_secs(300)).await;
2338
2339 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 #[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 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 #[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 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 #[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 writer.update_segment_metadata(vec![
2959 ("bucket".into(), "my-bucket".into()),
2960 ("service_name".into(), "my-svc".into()),
2961 ]);
2962
2963 writer.update_segment_metadata(vec![("runtime.main".into(), "0,1".into())]);
2966
2967 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 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 #[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 writer.write_encoded_batch(&test_batch()).unwrap();
3024
3025 writer.update_segment_metadata(entries.clone());
3027 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 #[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 #[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 #[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 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 #[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 let active_path = writer.current_active_path().to_owned();
3169 assert!(active_path.exists());
3170 std::fs::remove_file(&active_path).unwrap();
3171
3172 tokio::time::advance(Duration::from_secs(61)).await;
3174
3175 assert!(writer.should_drain(), "should_drain should fire");
3176
3177 writer
3181 .drained()
3182 .expect("drained() must recover from missing .active file");
3183
3184 assert!(
3187 !writer.should_drain(),
3188 "should_drain must return false after recovery (otherwise flush loop spins)"
3189 );
3190
3191 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 #[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 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 tokio::time::advance(Duration::from_millis(5)).await;
3247 let _ = writer.drained();
3248 assert!(!writer.should_drain());
3249 }
3250
3251 #[test]
3255 fn test_restart_seeds_closed_files_and_evicts() {
3256 let dir = TempDir::new().unwrap();
3257 let base = dir.path().join("trace");
3258 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 let new_budget = one_event + 1; 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 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 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 #[test]
3308 fn test_restart_discards_stale_active_files() {
3309 let dir = TempDir::new().unwrap();
3310 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 #[test]
3329 fn test_restart_counts_gz_siblings_toward_budget() {
3330 let dir = TempDir::new().unwrap();
3331 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 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 #[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 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 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 #[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 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 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 writer.finalize().unwrap();
3472
3473 let (capture, captured) = CapturingProcessor::new();
3474 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 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 #[tokio::test]
3519 async fn mem_writer_e2e_delivers_all_events_across_rotations() {
3520 const EVENTS: usize = 60;
3521
3522 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}