1use core::mem::size_of;
5use core::time::Duration;
6use std::collections::VecDeque;
7use std::time::Instant;
8
9use crate::model::SystemSnapshot;
10use crate::units::{ByteUnits, format_bytes, format_duration};
11
12use super::contributors::MAX_RETAINED_TEXT_BYTES;
13use super::{
14 Contributor, ContributorMetric, ContributorSet, HistoricalSample, HistoricalSystemMetrics,
15};
16
17pub const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_secs(1);
19pub const MIN_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
21pub const MAX_SAMPLE_INTERVAL: Duration = Duration::from_secs(60);
23
24pub const DEFAULT_HISTORY_DURATION: Duration = Duration::from_secs(5 * 60);
26pub const MIN_HISTORY_DURATION: Duration = Duration::from_secs(30);
28pub const MAX_HISTORY_DURATION: Duration = Duration::from_secs(60 * 60);
30
31pub const DEFAULT_TOP_CONTRIBUTORS_PER_METRIC: usize = 10;
33pub const MAX_TOP_CONTRIBUTORS_PER_METRIC: usize = 50;
39
40pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 32 * 1024 * 1024;
42pub const MIN_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024;
48pub const MAX_MEMORY_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct HistoryConfig {
62 pub interval: Duration,
64 pub duration: Duration,
66 pub top_contributors_per_metric: usize,
68 pub memory_budget_bytes: u64,
70}
71
72impl Default for HistoryConfig {
73 fn default() -> Self {
75 Self {
76 interval: DEFAULT_SAMPLE_INTERVAL,
77 duration: DEFAULT_HISTORY_DURATION,
78 top_contributors_per_metric: DEFAULT_TOP_CONTRIBUTORS_PER_METRIC,
79 memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
80 }
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
86pub enum HistoryField {
87 SampleInterval,
89 HistoryDuration,
91 TopContributorsPerMetric,
93 MemoryBudget,
95}
96
97impl HistoryField {
98 #[must_use]
103 pub const fn config_key(self) -> &'static str {
104 match self {
105 Self::SampleInterval => "sampling.interval",
106 Self::HistoryDuration => "sampling.history",
107 Self::TopContributorsPerMetric => "processes.top_contributors_per_metric",
108 Self::MemoryBudget => "sampling.max_history_memory",
109 }
110 }
111}
112
113#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
115pub enum ClampReason {
116 BelowMinimum,
118 AboveMaximum,
120 ExceedsMemoryBudget,
122}
123
124impl ClampReason {
125 #[must_use]
127 pub const fn explanation(self) -> &'static str {
128 match self {
129 Self::BelowMinimum => "below the supported minimum",
130 Self::AboveMaximum => "above the supported maximum",
131 Self::ExceedsMemoryBudget => "would exceed the history memory budget",
132 }
133 }
134}
135
136#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
138pub enum ClampedValue {
139 Duration(Duration),
141 Count(usize),
143 Bytes(u64),
145}
146
147impl ClampedValue {
148 #[must_use]
150 pub fn render(self) -> String {
151 match self {
152 Self::Duration(duration) => format_duration(duration),
153 Self::Count(count) => count.to_string(),
154 Self::Bytes(bytes) => format_bytes(bytes, ByteUnits::Iec),
158 }
159 }
160}
161
162#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
164pub struct HistoryClamp {
165 pub field: HistoryField,
167 pub requested: ClampedValue,
169 pub applied: ClampedValue,
171 pub reason: ClampReason,
173}
174
175impl HistoryClamp {
176 #[must_use]
178 pub fn message(&self) -> String {
179 format!(
180 "{} {} clamped to {}: {}",
181 self.field.config_key(),
182 self.requested.render(),
183 self.applied.render(),
184 self.reason.explanation()
185 )
186 }
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct HistoryLimits {
196 interval: Duration,
197 requested_duration: Duration,
198 capacity: usize,
199 top_contributors_per_metric: usize,
200 memory_budget_bytes: u64,
201 bytes_per_sample: usize,
202 clamps: Vec<HistoryClamp>,
203}
204
205impl HistoryLimits {
206 #[must_use]
208 pub fn resolve(config: HistoryConfig) -> Self {
209 let mut clamps = Vec::new();
210
211 let interval = clamp_duration(
212 config.interval,
213 MIN_SAMPLE_INTERVAL,
214 MAX_SAMPLE_INTERVAL,
215 HistoryField::SampleInterval,
216 &mut clamps,
217 );
218 let top_contributors_per_metric = clamp_count(
219 config.top_contributors_per_metric,
220 1,
221 MAX_TOP_CONTRIBUTORS_PER_METRIC,
222 HistoryField::TopContributorsPerMetric,
223 &mut clamps,
224 );
225 let memory_budget_bytes = clamp_bytes(
226 config.memory_budget_bytes,
227 MIN_MEMORY_BUDGET_BYTES,
228 MAX_MEMORY_BUDGET_BYTES,
229 HistoryField::MemoryBudget,
230 &mut clamps,
231 );
232 let duration = clamp_duration(
233 config.duration,
234 MIN_HISTORY_DURATION,
235 MAX_HISTORY_DURATION,
236 HistoryField::HistoryDuration,
237 &mut clamps,
238 );
239
240 let bytes_per_sample = estimated_bytes_per_sample(top_contributors_per_metric);
241 let requested_capacity = derive_capacity(duration, interval);
242 let affordable = affordable_capacity(memory_budget_bytes, bytes_per_sample);
243 let capacity = requested_capacity.min(affordable);
244
245 if capacity < requested_capacity {
246 clamps.push(HistoryClamp {
249 field: HistoryField::HistoryDuration,
250 requested: ClampedValue::Duration(duration),
251 applied: ClampedValue::Duration(interval.saturating_mul(capacity_as_u32(capacity))),
252 reason: ClampReason::ExceedsMemoryBudget,
253 });
254 }
255
256 Self {
257 interval,
258 requested_duration: duration,
259 capacity,
260 top_contributors_per_metric,
261 memory_budget_bytes,
262 bytes_per_sample,
263 clamps,
264 }
265 }
266
267 #[must_use]
269 pub const fn interval(&self) -> Duration {
270 self.interval
271 }
272
273 #[must_use]
278 pub const fn capacity(&self) -> usize {
279 self.capacity
280 }
281
282 #[must_use]
287 pub fn effective_duration(&self) -> Duration {
288 self.interval.saturating_mul(capacity_as_u32(self.capacity))
289 }
290
291 #[must_use]
294 pub const fn requested_duration(&self) -> Duration {
295 self.requested_duration
296 }
297
298 #[must_use]
300 pub const fn top_contributors_per_metric(&self) -> usize {
301 self.top_contributors_per_metric
302 }
303
304 #[must_use]
306 pub const fn memory_budget_bytes(&self) -> u64 {
307 self.memory_budget_bytes
308 }
309
310 #[must_use]
316 pub const fn estimated_bytes_per_sample(&self) -> usize {
317 self.bytes_per_sample
318 }
319
320 #[must_use]
322 pub const fn estimated_capacity_bytes(&self) -> usize {
323 self.capacity.saturating_mul(self.bytes_per_sample)
324 }
325
326 #[must_use]
331 pub fn clamps(&self) -> &[HistoryClamp] {
332 &self.clamps
333 }
334
335 #[must_use]
337 pub fn was_clamped(&self) -> bool {
338 !self.clamps.is_empty()
339 }
340}
341
342impl Default for HistoryLimits {
343 fn default() -> Self {
345 Self::resolve(HistoryConfig::default())
346 }
347}
348
349fn clamp_duration(
351 value: Duration,
352 min: Duration,
353 max: Duration,
354 field: HistoryField,
355 clamps: &mut Vec<HistoryClamp>,
356) -> Duration {
357 let (applied, reason) = if value < min {
358 (min, Some(ClampReason::BelowMinimum))
359 } else if value > max {
360 (max, Some(ClampReason::AboveMaximum))
361 } else {
362 (value, None)
363 };
364 if let Some(reason) = reason {
365 clamps.push(HistoryClamp {
366 field,
367 requested: ClampedValue::Duration(value),
368 applied: ClampedValue::Duration(applied),
369 reason,
370 });
371 }
372 applied
373}
374
375fn clamp_count(
377 value: usize,
378 min: usize,
379 max: usize,
380 field: HistoryField,
381 clamps: &mut Vec<HistoryClamp>,
382) -> usize {
383 let (applied, reason) = if value < min {
384 (min, Some(ClampReason::BelowMinimum))
385 } else if value > max {
386 (max, Some(ClampReason::AboveMaximum))
387 } else {
388 (value, None)
389 };
390 if let Some(reason) = reason {
391 clamps.push(HistoryClamp {
392 field,
393 requested: ClampedValue::Count(value),
394 applied: ClampedValue::Count(applied),
395 reason,
396 });
397 }
398 applied
399}
400
401fn clamp_bytes(
403 value: u64,
404 min: u64,
405 max: u64,
406 field: HistoryField,
407 clamps: &mut Vec<HistoryClamp>,
408) -> u64 {
409 let (applied, reason) = if value < min {
410 (min, Some(ClampReason::BelowMinimum))
411 } else if value > max {
412 (max, Some(ClampReason::AboveMaximum))
413 } else {
414 (value, None)
415 };
416 if let Some(reason) = reason {
417 clamps.push(HistoryClamp {
418 field,
419 requested: ClampedValue::Bytes(value),
420 applied: ClampedValue::Bytes(applied),
421 reason,
422 });
423 }
424 applied
425}
426
427fn derive_capacity(duration: Duration, interval: Duration) -> usize {
432 let interval_nanos = interval.as_nanos();
433 if interval_nanos == 0 {
434 return 1;
435 }
436 let samples = duration.as_nanos().div_ceil(interval_nanos).max(1);
437 usize::try_from(samples).unwrap_or(usize::MAX)
438}
439
440fn affordable_capacity(budget: u64, bytes_per_sample: usize) -> usize {
442 let per_sample = u64::try_from(bytes_per_sample.max(1)).unwrap_or(u64::MAX);
443 let samples = (budget / per_sample).max(1);
444 usize::try_from(samples).unwrap_or(usize::MAX)
445}
446
447fn capacity_as_u32(capacity: usize) -> u32 {
452 u32::try_from(capacity).unwrap_or(u32::MAX)
453}
454
455const fn estimated_bytes_per_sample(top_contributors_per_metric: usize) -> usize {
460 let per_contributor = size_of::<Contributor>() + MAX_RETAINED_TEXT_BYTES;
461 let contributors = top_contributors_per_metric
462 .saturating_mul(ContributorMetric::COUNT)
463 .saturating_mul(per_contributor);
464 size_of::<HistoricalSample>().saturating_add(contributors)
465}
466
467#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
469pub enum RecordOutcome {
470 Recorded {
472 evicted: bool,
474 },
475 NotNewer,
481}
482
483impl RecordOutcome {
484 #[must_use]
486 pub const fn is_recorded(self) -> bool {
487 matches!(self, Self::Recorded { .. })
488 }
489}
490
491#[derive(Debug)]
497pub struct HistoryRing {
498 limits: HistoryLimits,
499 start: Instant,
500 samples: VecDeque<HistoricalSample>,
501 total_recorded: u64,
502 evicted: u64,
503 retained_heap_bytes: usize,
504}
505
506impl HistoryRing {
507 #[must_use]
513 pub fn new(limits: HistoryLimits, start: Instant) -> Self {
514 Self {
515 samples: VecDeque::with_capacity(limits.capacity()),
516 limits,
517 start,
518 total_recorded: 0,
519 evicted: 0,
520 retained_heap_bytes: 0,
521 }
522 }
523
524 #[must_use]
528 pub fn with_config(config: HistoryConfig, start: Instant) -> Self {
529 Self::new(HistoryLimits::resolve(config), start)
530 }
531
532 pub fn record(&mut self, snapshot: &SystemSnapshot) -> RecordOutcome {
537 let offset = snapshot.captured_at.saturating_duration_since(self.start);
538 if let Some(newest) = self.samples.back()
539 && (snapshot.sequence <= newest.sequence || offset < newest.monotonic_offset)
540 {
541 return RecordOutcome::NotNewer;
542 }
543
544 let contributors = {
545 let previous = self.samples.back().map(|sample| &sample.contributors);
546 ContributorSet::from_processes(
547 &snapshot.processes,
548 previous,
549 self.limits.top_contributors_per_metric(),
550 )
551 };
552 let sample = HistoricalSample {
553 sequence: snapshot.sequence,
554 monotonic_offset: offset,
555 wall_time: snapshot.wall_time,
556 system: HistoricalSystemMetrics::from_snapshot(snapshot),
557 contributors,
558 };
559
560 let mut evicted = false;
561 while self.samples.len() >= self.limits.capacity() {
562 let Some(oldest) = self.samples.pop_front() else {
563 break;
564 };
565 self.retained_heap_bytes = self
566 .retained_heap_bytes
567 .saturating_sub(oldest.contributors.heap_bytes());
568 self.evicted = self.evicted.saturating_add(1);
569 evicted = true;
570 }
571
572 self.retained_heap_bytes = self
573 .retained_heap_bytes
574 .saturating_add(sample.contributors.heap_bytes());
575 self.samples.push_back(sample);
576 self.total_recorded = self.total_recorded.saturating_add(1);
577 RecordOutcome::Recorded { evicted }
578 }
579
580 #[must_use]
582 pub const fn limits(&self) -> &HistoryLimits {
583 &self.limits
584 }
585
586 #[must_use]
588 pub fn clamps(&self) -> &[HistoryClamp] {
589 self.limits.clamps()
590 }
591
592 #[must_use]
594 pub const fn start(&self) -> Instant {
595 self.start
596 }
597
598 #[must_use]
600 pub const fn capacity(&self) -> usize {
601 self.limits.capacity()
602 }
603
604 #[must_use]
606 pub fn len(&self) -> usize {
607 self.samples.len()
608 }
609
610 #[must_use]
612 pub fn is_empty(&self) -> bool {
613 self.samples.is_empty()
614 }
615
616 #[must_use]
618 pub fn samples(&self) -> impl DoubleEndedIterator<Item = &HistoricalSample> {
619 self.samples.iter()
620 }
621
622 #[must_use]
626 pub fn get(&self, index: usize) -> Option<&HistoricalSample> {
627 self.samples.get(index)
628 }
629
630 #[must_use]
632 pub fn newest(&self) -> Option<&HistoricalSample> {
633 self.samples.back()
634 }
635
636 #[must_use]
638 pub fn oldest(&self) -> Option<&HistoricalSample> {
639 self.samples.front()
640 }
641
642 #[must_use]
648 pub const fn total_recorded(&self) -> u64 {
649 self.total_recorded
650 }
651
652 #[must_use]
654 pub const fn evicted(&self) -> u64 {
655 self.evicted
656 }
657
658 #[must_use]
660 pub fn first_absolute(&self) -> u64 {
661 let len = u64::try_from(self.samples.len()).unwrap_or(u64::MAX);
662 self.total_recorded.saturating_sub(len)
663 }
664
665 #[must_use]
667 pub fn newest_absolute(&self) -> Option<u64> {
668 if self.samples.is_empty() {
669 None
670 } else {
671 self.total_recorded.checked_sub(1)
672 }
673 }
674
675 #[must_use]
679 pub fn get_absolute(&self, absolute: u64) -> Option<&HistoricalSample> {
680 let relative = absolute.checked_sub(self.first_absolute())?;
681 self.samples.get(usize::try_from(relative).ok()?)
682 }
683
684 #[must_use]
686 pub fn span(&self) -> Duration {
687 match (self.oldest(), self.newest()) {
688 (Some(oldest), Some(newest)) => newest
689 .monotonic_offset
690 .saturating_sub(oldest.monotonic_offset),
691 _ => Duration::ZERO,
692 }
693 }
694
695 #[must_use]
701 pub fn index_at_or_before_offset(&self, offset: Duration) -> Option<usize> {
702 let past_target = partition_point(self.samples.len(), |index| {
703 self.samples
704 .get(index)
705 .is_some_and(|sample| sample.monotonic_offset <= offset)
706 });
707 past_target.checked_sub(1)
708 }
709
710 #[must_use]
715 pub fn estimated_bytes(&self) -> usize {
716 size_of::<Self>()
717 .saturating_add(self.samples.capacity() * size_of::<HistoricalSample>())
718 .saturating_add(self.retained_heap_bytes)
719 }
720}
721
722fn partition_point(len: usize, mut predicate: impl FnMut(usize) -> bool) -> usize {
728 let mut low = 0usize;
729 let mut high = len;
730 while low < high {
731 let middle = low + (high - low) / 2;
732 if predicate(middle) {
733 low = middle + 1;
734 } else {
735 high = middle;
736 }
737 }
738 low
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use crate::history::HistoryMetric;
745 use crate::model::{
746 CpuUsage, DiskSnapshot, MetricState, ProcessIdentity, ProcessIo, ProcessMemory,
747 ProcessSnapshot, ProcessState, UnavailableReason,
748 };
749 use crate::units::{Percent, Rate};
750 use std::time::SystemTime;
751
752 fn snapshot(start: Instant, sequence: u64, interval: Duration) -> SystemSnapshot {
754 let captured_at = start + interval.saturating_mul(u32::try_from(sequence).unwrap_or(0));
755 let mut snapshot = SystemSnapshot::warming_up(
756 captured_at,
757 SystemTime::UNIX_EPOCH + Duration::from_secs(sequence),
758 8,
759 );
760 snapshot.sequence = sequence;
761 snapshot.elapsed = interval;
762 snapshot
763 }
764
765 fn with_cpu(mut snapshot: SystemSnapshot, busy: f32) -> SystemSnapshot {
766 snapshot.cpu.total =
767 MetricState::Available(CpuUsage::plain(Percent::new(busy).expect("valid percent")));
768 snapshot
769 }
770
771 fn process(pid: u32, cpu: f32) -> ProcessSnapshot {
772 ProcessSnapshot {
773 identity: ProcessIdentity::new(pid, u64::from(pid) * 31),
774 parent_pid: Some(1),
775 name: "proc".into(),
776 command: "proc --flag".into(),
777 exe: None,
778 user: MetricState::Unsupported,
779 state: ProcessState::Running,
780 cpu: MetricState::Available(Percent::new(cpu).expect("valid percent")),
781 memory: ProcessMemory {
782 rss_bytes: MetricState::Available(u64::from(pid) * 1024),
783 virtual_bytes: MetricState::Unsupported,
784 share_of_total: MetricState::Unsupported,
785 },
786 io: ProcessIo {
787 read: MetricState::Available(Rate::new(f64::from(pid)).expect("valid rate")),
788 write: MetricState::Available(Rate::new(f64::from(pid)).expect("valid rate")),
789 read_total_bytes: MetricState::Unsupported,
790 write_total_bytes: MetricState::Unsupported,
791 },
792 threads: MetricState::Unsupported,
793 age: MetricState::Unsupported,
794 started_at: MetricState::Unsupported,
795 is_kernel_thread: false,
796 }
797 }
798
799 #[test]
800 fn the_default_configuration_holds_three_hundred_one_second_samples() {
801 let limits = HistoryLimits::default();
803 assert_eq!(limits.capacity(), 300);
804 assert_eq!(limits.interval(), Duration::from_secs(1));
805 assert_eq!(limits.effective_duration(), Duration::from_secs(300));
806 assert_eq!(limits.top_contributors_per_metric(), 10);
807 assert!(!limits.was_clamped(), "{:?}", limits.clamps());
808 }
809
810 #[test]
811 fn capacity_is_derived_from_interval_and_duration() {
812 let cases = [
813 (Duration::from_millis(250), Duration::from_secs(60), 240),
814 (Duration::from_secs(1), Duration::from_secs(30), 30),
815 (Duration::from_secs(2), Duration::from_secs(300), 150),
816 (Duration::from_secs(60), Duration::from_secs(3_600), 60),
817 ];
818 for (interval, duration, expected) in cases {
819 let limits = HistoryLimits::resolve(HistoryConfig {
820 interval,
821 duration,
822 ..HistoryConfig::default()
823 });
824 assert_eq!(
825 limits.capacity(),
826 expected,
827 "{interval:?} over {duration:?}"
828 );
829 }
830 }
831
832 #[test]
833 fn a_duration_that_is_not_a_whole_number_of_intervals_rounds_up() {
834 let limits = HistoryLimits::resolve(HistoryConfig {
836 interval: Duration::from_millis(400),
837 duration: Duration::from_secs(31),
838 ..HistoryConfig::default()
839 });
840 assert_eq!(limits.capacity(), 78, "ceil(31000 / 400) is 78");
841 }
842
843 #[test]
844 fn an_interval_below_the_minimum_is_clamped_and_reported() {
845 let limits = HistoryLimits::resolve(HistoryConfig {
846 interval: Duration::from_millis(10),
847 ..HistoryConfig::default()
848 });
849 assert_eq!(limits.interval(), MIN_SAMPLE_INTERVAL);
850
851 let clamp = limits
852 .clamps()
853 .iter()
854 .find(|clamp| clamp.field == HistoryField::SampleInterval)
855 .expect("the clamp is reported so the UI can warn");
856 assert_eq!(clamp.reason, ClampReason::BelowMinimum);
857 assert_eq!(
858 clamp.requested,
859 ClampedValue::Duration(Duration::from_millis(10))
860 );
861 assert_eq!(clamp.applied, ClampedValue::Duration(MIN_SAMPLE_INTERVAL));
862 assert_eq!(
863 clamp.message(),
864 "sampling.interval 10ms clamped to 250ms: below the supported minimum"
865 );
866 }
867
868 #[test]
869 fn every_out_of_range_value_is_clamped_and_reported() {
870 let limits = HistoryLimits::resolve(HistoryConfig {
871 interval: Duration::from_secs(600),
872 duration: Duration::from_secs(1),
873 top_contributors_per_metric: 0,
874 memory_budget_bytes: 1,
875 });
876
877 assert_eq!(limits.interval(), MAX_SAMPLE_INTERVAL);
878 assert_eq!(limits.requested_duration(), MIN_HISTORY_DURATION);
879 assert_eq!(limits.top_contributors_per_metric(), 1);
880 assert_eq!(limits.memory_budget_bytes(), MIN_MEMORY_BUDGET_BYTES);
881
882 let fields: Vec<HistoryField> = limits.clamps().iter().map(|c| c.field).collect();
883 for field in [
884 HistoryField::SampleInterval,
885 HistoryField::HistoryDuration,
886 HistoryField::TopContributorsPerMetric,
887 HistoryField::MemoryBudget,
888 ] {
889 assert!(fields.contains(&field), "{field:?} was not reported");
890 }
891 for clamp in limits.clamps() {
892 assert!(clamp.message().contains(clamp.field.config_key()));
893 }
894 }
895
896 #[test]
897 fn an_in_range_configuration_reports_no_clamp() {
898 let limits = HistoryLimits::resolve(HistoryConfig {
899 interval: Duration::from_secs(2),
900 duration: Duration::from_secs(600),
901 top_contributors_per_metric: 5,
902 memory_budget_bytes: 8 * 1024 * 1024,
903 });
904 assert!(!limits.was_clamped(), "{:?}", limits.clamps());
905 assert_eq!(limits.capacity(), 300);
906 }
907
908 #[test]
909 fn the_memory_budget_shrinks_capacity_and_reports_the_shorter_history() {
910 let limits = HistoryLimits::resolve(HistoryConfig {
912 interval: MIN_SAMPLE_INTERVAL,
913 duration: MAX_HISTORY_DURATION,
914 top_contributors_per_metric: MAX_TOP_CONTRIBUTORS_PER_METRIC,
915 memory_budget_bytes: MIN_MEMORY_BUDGET_BYTES,
916 });
917
918 assert!(
919 limits.capacity() < derive_capacity(MAX_HISTORY_DURATION, MIN_SAMPLE_INTERVAL),
920 "capacity should have been reduced"
921 );
922 assert!(limits.capacity() >= 1, "at least one sample must fit");
923 assert!(
924 u64::try_from(limits.estimated_capacity_bytes()).unwrap_or(u64::MAX)
925 <= limits.memory_budget_bytes(),
926 "worst case {} exceeds budget {}",
927 limits.estimated_capacity_bytes(),
928 limits.memory_budget_bytes()
929 );
930
931 let clamp = limits
932 .clamps()
933 .iter()
934 .find(|clamp| clamp.reason == ClampReason::ExceedsMemoryBudget)
935 .expect("the budget clamp is reported");
936 assert_eq!(clamp.field, HistoryField::HistoryDuration);
937 assert!(
938 clamp.message().contains("memory budget"),
939 "{}",
940 clamp.message()
941 );
942 assert!(limits.effective_duration() < MAX_HISTORY_DURATION);
943 }
944
945 #[test]
946 fn the_worst_case_estimate_grows_with_the_contributor_count() {
947 let small = estimated_bytes_per_sample(1);
948 let large = estimated_bytes_per_sample(10);
949 assert!(small < large);
950 assert!(estimated_bytes_per_sample(0) >= size_of::<HistoricalSample>());
951 }
952
953 #[test]
954 fn a_full_ring_evicts_the_oldest_sample() {
955 let start = Instant::now();
956 let interval = Duration::from_secs(1);
957 let mut ring = HistoryRing::new(
958 HistoryLimits::resolve(HistoryConfig {
959 interval,
960 duration: Duration::from_secs(30),
961 ..HistoryConfig::default()
962 }),
963 start,
964 );
965 assert_eq!(ring.capacity(), 30);
966 assert!(ring.is_empty());
967
968 for sequence in 0..30 {
969 let outcome = ring.record(&snapshot(start, sequence, interval));
970 assert_eq!(outcome, RecordOutcome::Recorded { evicted: false });
971 }
972 assert_eq!(ring.len(), 30);
973 assert_eq!(ring.evicted(), 0);
974 assert_eq!(ring.oldest().map(|s| s.sequence), Some(0));
975
976 let outcome = ring.record(&snapshot(start, 30, interval));
977 assert_eq!(outcome, RecordOutcome::Recorded { evicted: true });
978 assert_eq!(ring.len(), 30, "capacity is never exceeded");
979 assert_eq!(ring.evicted(), 1);
980 assert_eq!(ring.oldest().map(|s| s.sequence), Some(1));
981 assert_eq!(ring.newest().map(|s| s.sequence), Some(30));
982 assert_eq!(ring.total_recorded(), 31);
983 }
984
985 #[test]
986 fn eviction_keeps_absolute_indexing_stable() {
987 let start = Instant::now();
988 let interval = Duration::from_secs(1);
989 let mut ring = HistoryRing::new(
990 HistoryLimits::resolve(HistoryConfig {
991 interval,
992 duration: Duration::from_secs(30),
993 ..HistoryConfig::default()
994 }),
995 start,
996 );
997 for sequence in 0..40 {
998 ring.record(&snapshot(start, sequence, interval));
999 }
1000
1001 assert_eq!(ring.first_absolute(), 10);
1002 assert_eq!(ring.newest_absolute(), Some(39));
1003 assert_eq!(ring.get_absolute(10).map(|s| s.sequence), Some(10));
1004 assert_eq!(ring.get_absolute(39).map(|s| s.sequence), Some(39));
1005 assert!(ring.get_absolute(9).is_none(), "evicted samples are gone");
1006 assert!(ring.get_absolute(40).is_none(), "the future does not exist");
1007 }
1008
1009 #[test]
1010 fn an_empty_ring_has_no_newest_index() {
1011 let ring = HistoryRing::new(HistoryLimits::default(), Instant::now());
1012 assert_eq!(ring.newest_absolute(), None);
1013 assert_eq!(ring.first_absolute(), 0);
1014 assert!(ring.get_absolute(0).is_none());
1015 assert_eq!(ring.span(), Duration::ZERO);
1016 assert!(ring.index_at_or_before_offset(Duration::ZERO).is_none());
1017 }
1018
1019 #[test]
1020 fn a_resent_or_coalesced_snapshot_does_not_push_history_backwards() {
1021 let start = Instant::now();
1022 let interval = Duration::from_secs(1);
1023 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1024
1025 assert!(ring.record(&snapshot(start, 5, interval)).is_recorded());
1026 assert_eq!(
1027 ring.record(&snapshot(start, 5, interval)),
1028 RecordOutcome::NotNewer,
1029 "the same sequence must not be recorded twice"
1030 );
1031 assert_eq!(
1032 ring.record(&snapshot(start, 4, interval)),
1033 RecordOutcome::NotNewer,
1034 "an older sequence must not be appended"
1035 );
1036 assert_eq!(ring.len(), 1);
1037 assert_eq!(ring.total_recorded(), 1);
1038 }
1039
1040 #[test]
1041 fn offsets_are_measured_from_the_rings_start_instant() {
1042 let start = Instant::now();
1043 let interval = Duration::from_secs(1);
1044 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1045 for sequence in 0..5 {
1046 ring.record(&snapshot(start, sequence, interval));
1047 }
1048
1049 let offsets: Vec<Duration> = ring.samples().map(|s| s.monotonic_offset).collect();
1050 assert_eq!(
1051 offsets,
1052 vec![
1053 Duration::ZERO,
1054 Duration::from_secs(1),
1055 Duration::from_secs(2),
1056 Duration::from_secs(3),
1057 Duration::from_secs(4),
1058 ]
1059 );
1060 assert_eq!(ring.span(), Duration::from_secs(4));
1061 }
1062
1063 #[test]
1064 fn a_snapshot_captured_before_the_ring_started_gets_a_zero_offset() {
1065 let start = Instant::now();
1067 let Some(earlier) = start.checked_sub(Duration::from_secs(5)) else {
1068 return;
1069 };
1070 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1071 let mut source = SystemSnapshot::warming_up(earlier, SystemTime::UNIX_EPOCH, 8);
1072 source.sequence = 1;
1073
1074 assert!(ring.record(&source).is_recorded());
1075 assert_eq!(
1076 ring.newest().map(|s| s.monotonic_offset),
1077 Some(Duration::ZERO)
1078 );
1079 }
1080
1081 #[test]
1082 fn the_process_table_is_never_cloned_into_a_sample() {
1083 let start = Instant::now();
1086 let interval = Duration::from_secs(1);
1087 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1088
1089 let mut source = snapshot(start, 1, interval);
1090 source
1091 .processes
1092 .extend((1..=10_000u32).map(|pid| process(pid, 1.0)));
1093 assert_eq!(source.process_count(), 10_000);
1094
1095 assert!(ring.record(&source).is_recorded());
1096 let sample = ring.newest().expect("recorded");
1097 let top_k = ring.limits().top_contributors_per_metric();
1098 assert_eq!(
1099 sample.contributors.retained_count(),
1100 ContributorSet::max_retained(top_k)
1101 );
1102 assert!(sample.contributors.retained_count() <= top_k * 4);
1103 assert!(
1104 sample.estimated_bytes() <= ring.limits().estimated_bytes_per_sample(),
1105 "{} exceeded the budgeted {}",
1106 sample.estimated_bytes(),
1107 ring.limits().estimated_bytes_per_sample()
1108 );
1109 }
1110
1111 #[test]
1112 fn retained_bytes_stay_bounded_by_the_budget_over_a_long_run() {
1113 let start = Instant::now();
1116 let interval = Duration::from_secs(1);
1117 let mut ring = HistoryRing::new(
1118 HistoryLimits::resolve(HistoryConfig {
1119 interval,
1120 duration: Duration::from_secs(30),
1121 ..HistoryConfig::default()
1122 }),
1123 start,
1124 );
1125
1126 let mut peak = 0usize;
1127 for sequence in 0..300 {
1128 let mut source = snapshot(start, sequence, interval);
1129 source
1130 .processes
1131 .extend((1..=200u32).map(|pid| process(pid, 1.0)));
1132 ring.record(&source);
1133 peak = peak.max(ring.estimated_bytes());
1134 }
1135 assert_eq!(ring.len(), 30);
1136 assert_eq!(ring.estimated_bytes(), peak, "accounting must not drift");
1137 assert!(
1138 u64::try_from(ring.estimated_bytes()).unwrap_or(u64::MAX)
1139 <= ring.limits().memory_budget_bytes()
1140 );
1141 }
1142
1143 #[test]
1144 fn binary_search_finds_the_newest_sample_at_or_before_an_offset() {
1145 let start = Instant::now();
1146 let interval = Duration::from_secs(1);
1147 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1148 for sequence in 0..10 {
1149 ring.record(&snapshot(start, sequence, interval));
1150 }
1151
1152 assert_eq!(ring.index_at_or_before_offset(Duration::ZERO), Some(0));
1153 assert_eq!(
1154 ring.index_at_or_before_offset(Duration::from_secs(4)),
1155 Some(4)
1156 );
1157 assert_eq!(
1158 ring.index_at_or_before_offset(Duration::from_millis(4_500)),
1159 Some(4),
1160 "an offset between samples resolves to the older one"
1161 );
1162 assert_eq!(
1163 ring.index_at_or_before_offset(Duration::from_secs(99)),
1164 Some(9)
1165 );
1166 }
1167
1168 #[test]
1169 fn an_offset_older_than_the_whole_ring_has_no_sample() {
1170 let start = Instant::now();
1171 let interval = Duration::from_secs(1);
1172 let mut ring = HistoryRing::new(
1173 HistoryLimits::resolve(HistoryConfig {
1174 interval,
1175 duration: Duration::from_secs(30),
1176 ..HistoryConfig::default()
1177 }),
1178 start,
1179 );
1180 for sequence in 0..40 {
1181 ring.record(&snapshot(start, sequence, interval));
1182 }
1183 assert_eq!(ring.index_at_or_before_offset(Duration::from_secs(5)), None);
1185 assert_eq!(
1186 ring.index_at_or_before_offset(Duration::from_secs(10)),
1187 Some(0)
1188 );
1189 }
1190
1191 #[test]
1192 fn seeking_probes_a_logarithmic_number_of_samples() {
1193 for len in [1usize, 10, 300, 10_000, 1_000_000] {
1196 let mut probes = 0usize;
1197 let found = partition_point(len, |_| {
1198 probes += 1;
1199 true
1200 });
1201 assert_eq!(found, len);
1202 let bound =
1203 usize::try_from(usize::BITS - len.leading_zeros() + 1).unwrap_or(usize::MAX);
1204 assert!(
1205 probes <= bound,
1206 "len {len} took {probes} probes, expected at most {bound}"
1207 );
1208 }
1209 }
1210
1211 #[test]
1212 fn a_counter_reset_is_retained_as_unavailable_rather_than_a_spike() {
1213 let start = Instant::now();
1215 let interval = Duration::from_secs(1);
1216 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1217
1218 let mut busy = with_cpu(snapshot(start, 1, interval), 20.0);
1219 let mut disk = DiskSnapshot::warming_up("nvme0n1".into());
1220 disk.read = MetricState::Available(Rate::new(1_000_000.0).expect("valid rate"));
1221 busy.disks.push(disk);
1222 ring.record(&busy);
1223
1224 let mut reset = with_cpu(snapshot(start, 2, interval), 22.0);
1225 let mut disk = DiskSnapshot::warming_up("nvme0n1".into());
1226 disk.read = MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
1227 reset.disks.push(disk);
1228 ring.record(&reset);
1229
1230 let sample = ring.newest().expect("recorded");
1231 assert_eq!(
1232 sample.system.disk_read,
1233 MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset)
1234 );
1235 assert!(
1236 sample.system.scalar(HistoryMetric::DiskRead).is_none(),
1237 "an unavailable input must not produce a number to spike with"
1238 );
1239 }
1240
1241 #[test]
1242 fn a_capacity_of_one_still_records_and_evicts() {
1243 let start = Instant::now();
1244 let interval = Duration::from_secs(60);
1245 let mut ring = HistoryRing::new(
1246 HistoryLimits::resolve(HistoryConfig {
1247 interval,
1248 duration: Duration::from_secs(30),
1249 ..HistoryConfig::default()
1250 }),
1251 start,
1252 );
1253 assert_eq!(ring.capacity(), 1);
1254 assert!(ring.record(&snapshot(start, 1, interval)).is_recorded());
1255 assert_eq!(
1256 ring.record(&snapshot(start, 2, interval)),
1257 RecordOutcome::Recorded { evicted: true }
1258 );
1259 assert_eq!(ring.len(), 1);
1260 assert_eq!(ring.newest().map(|s| s.sequence), Some(2));
1261 }
1262
1263 #[test]
1264 fn the_clamped_value_renderer_round_trips_configuration_syntax() {
1265 assert_eq!(
1266 ClampedValue::Duration(Duration::from_millis(250)).render(),
1267 "250ms"
1268 );
1269 assert_eq!(ClampedValue::Count(10).render(), "10");
1270 assert_eq!(
1271 ClampedValue::Bytes(32 * 1024 * 1024).render(),
1272 "32 MiB",
1273 "the key is written as 32MiB in configuration"
1274 );
1275 }
1276
1277 #[test]
1278 fn a_ring_built_from_a_config_reports_its_own_clamps() {
1279 let ring = HistoryRing::with_config(
1280 HistoryConfig {
1281 interval: Duration::from_millis(1),
1282 ..HistoryConfig::default()
1283 },
1284 Instant::now(),
1285 );
1286 assert!(!ring.clamps().is_empty());
1287 assert_eq!(ring.limits().interval(), MIN_SAMPLE_INTERVAL);
1288 }
1289}