1use std::time::Instant;
23
24const SIGNIFICANT_PCT: u64 = 2;
31const RELEASE_PCT: u64 = 1;
32
33const SETTLE_GROWTH_PCT: u64 = 1;
39const SETTLE_STREAK: u32 = 4;
40
41const SETTLE_DEADLINE_SAMPLES: u32 = 240;
45
46#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
48pub enum DriftVerdict {
49 #[default]
51 Settled,
52 Heap,
54 OutsideHeap,
57 Both,
59}
60
61impl DriftVerdict {
62 pub fn label(self) -> &'static str {
64 match self {
65 DriftVerdict::Settled => "settled",
66 DriftVerdict::Heap => "heap",
67 DriftVerdict::OutsideHeap => "outside-heap",
68 DriftVerdict::Both => "heap and outside-heap",
69 }
70 }
71}
72
73#[derive(Clone, Copy, PartialEq, Eq, Debug)]
76pub struct MemoryDrift {
77 pub heap_growth_bytes: i64,
79 pub outside_heap_growth_bytes: i64,
81 pub window_secs: u64,
85 pub verdict: DriftVerdict,
87}
88
89#[derive(Clone, Copy, Debug)]
91struct Baseline {
92 at: Instant,
93 rss_bytes: u64,
94 heap_live_bytes: u64,
95}
96
97#[derive(Debug, Default)]
101pub(crate) struct DriftTracker {
102 baseline: Option<Baseline>,
103 last_rss: Option<u64>,
105 flat_streak: u32,
108 samples_before_baseline: u32,
109 heap_moved: bool,
112 outside_heap_moved: bool,
113}
114
115impl DriftTracker {
116 pub(crate) fn sample(
118 &mut self,
119 rss_bytes: u64,
120 heap_live_bytes: u64,
121 budget_bytes: u64,
122 ) -> Option<MemoryDrift> {
123 self.sample_at(rss_bytes, heap_live_bytes, budget_bytes, Instant::now())
124 }
125
126 fn sample_at(
128 &mut self,
129 rss_bytes: u64,
130 heap_live_bytes: u64,
131 budget_bytes: u64,
132 now: Instant,
133 ) -> Option<MemoryDrift> {
134 let Some(base) = self.baseline else {
135 self.settle(rss_bytes, heap_live_bytes, now);
136 return None;
137 };
138 let heap_growth = growth(heap_live_bytes, base.heap_live_bytes);
139 let outside_heap_growth = growth(rss_bytes, base.rss_bytes) - heap_growth;
140 self.heap_moved = moved(heap_growth, budget_bytes, self.heap_moved);
141 self.outside_heap_moved = moved(outside_heap_growth, budget_bytes, self.outside_heap_moved);
142 Some(MemoryDrift {
143 heap_growth_bytes: heap_growth,
144 outside_heap_growth_bytes: outside_heap_growth,
145 window_secs: now.saturating_duration_since(base.at).as_secs(),
146 verdict: verdict(self.heap_moved, self.outside_heap_moved),
147 })
148 }
149
150 fn settle(&mut self, rss_bytes: u64, heap_live_bytes: u64, now: Instant) {
153 self.samples_before_baseline = self.samples_before_baseline.saturating_add(1);
154 let flat = self.last_rss.is_some_and(|prev| {
155 rss_bytes.saturating_sub(prev) <= prev.saturating_mul(SETTLE_GROWTH_PCT) / 100
156 });
157 self.flat_streak = if flat { self.flat_streak + 1 } else { 0 };
158 self.last_rss = Some(rss_bytes);
159 if self.flat_streak >= SETTLE_STREAK
160 || self.samples_before_baseline >= SETTLE_DEADLINE_SAMPLES
161 {
162 self.baseline = Some(Baseline {
163 at: now,
164 rss_bytes,
165 heap_live_bytes,
166 });
167 }
168 }
169}
170
171fn growth(now: u64, base: u64) -> i64 {
174 (now as i128 - base as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64
175}
176
177fn moved(growth: i64, budget_bytes: u64, was_moved: bool) -> bool {
183 let pct = if was_moved {
184 RELEASE_PCT
185 } else {
186 SIGNIFICANT_PCT
187 };
188 let threshold = budget_bytes.saturating_mul(pct) / 100;
189 threshold > 0 && growth > 0 && growth as u64 > threshold
190}
191
192fn verdict(heap_moved: bool, outside_heap_moved: bool) -> DriftVerdict {
193 match (heap_moved, outside_heap_moved) {
194 (true, true) => DriftVerdict::Both,
195 (true, false) => DriftVerdict::Heap,
196 (false, true) => DriftVerdict::OutsideHeap,
197 (false, false) => DriftVerdict::Settled,
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::time::Duration;
205
206 const MIB: u64 = 1024 * 1024;
207 const BUDGET: u64 = 1000 * MIB;
208 const SIGNIFICANT: u64 = 20 * MIB;
210
211 fn settled(rss: u64, heap: u64, at: Instant) -> DriftTracker {
214 let mut t = DriftTracker::default();
215 for _ in 0..=SETTLE_STREAK {
216 assert_eq!(t.sample_at(rss, heap, BUDGET, at), None);
217 }
218 assert!(
219 t.baseline.is_some(),
220 "a run of steady samples settles the baseline"
221 );
222 t
223 }
224
225 #[test]
228 fn a_climbing_rss_against_a_flat_heap_reads_as_outside_the_heap() {
229 let start = Instant::now();
230 let mut t = settled(2000 * MIB, 400 * MIB, start);
231
232 let d = t
233 .sample_at(
234 2400 * MIB,
235 400 * MIB,
236 BUDGET,
237 start + Duration::from_secs(3600),
238 )
239 .expect("the baseline is captured");
240 assert_eq!(d.verdict, DriftVerdict::OutsideHeap);
241 assert_eq!(d.heap_growth_bytes, 0);
242 assert_eq!(d.outside_heap_growth_bytes, (400 * MIB) as i64);
243 assert_eq!(d.window_secs, 3600);
244 }
245
246 #[test]
249 fn a_climbing_heap_carrying_the_rss_reads_as_ours() {
250 let start = Instant::now();
251 let mut t = settled(2000 * MIB, 400 * MIB, start);
252
253 let d = t
254 .sample_at(
255 2400 * MIB,
256 800 * MIB,
257 BUDGET,
258 start + Duration::from_secs(60),
259 )
260 .expect("the baseline is captured");
261 assert_eq!(d.verdict, DriftVerdict::Heap);
262 assert_eq!(d.heap_growth_bytes, (400 * MIB) as i64);
263 assert_eq!(d.outside_heap_growth_bytes, 0);
265 }
266
267 #[test]
268 fn both_terms_growing_are_reported_as_both() {
269 let start = Instant::now();
270 let mut t = settled(2000 * MIB, 400 * MIB, start);
271 let d = t
272 .sample_at(2600 * MIB, 700 * MIB, BUDGET, start)
273 .expect("the baseline is captured");
274 assert_eq!(d.verdict, DriftVerdict::Both);
275 assert_eq!(d.heap_growth_bytes, (300 * MIB) as i64);
276 assert_eq!(d.outside_heap_growth_bytes, (300 * MIB) as i64);
277 }
278
279 #[test]
282 fn movement_under_the_threshold_stays_settled() {
283 let start = Instant::now();
284 let mut t = settled(2000 * MIB, 400 * MIB, start);
285 let d = t
286 .sample_at(
287 2000 * MIB + SIGNIFICANT - 1,
288 400 * MIB + SIGNIFICANT - 1,
289 BUDGET,
290 start,
291 )
292 .expect("the baseline is captured");
293 assert_eq!(d.verdict, DriftVerdict::Settled);
294 }
295
296 #[test]
302 fn a_growth_hovering_at_the_threshold_holds_its_reading() {
303 let start = Instant::now();
304 let mut t = settled(2000 * MIB, 400 * MIB, start);
305 let rss_at = |outside: u64| 2000 * MIB + outside;
306
307 let d = t
309 .sample_at(rss_at(SIGNIFICANT + MIB), 400 * MIB, BUDGET, start)
310 .expect("the baseline is captured");
311 assert_eq!(d.verdict, DriftVerdict::OutsideHeap);
312
313 for outside in [SIGNIFICANT - MIB, SIGNIFICANT + MIB, SIGNIFICANT - MIB] {
316 let d = t
317 .sample_at(rss_at(outside), 400 * MIB, BUDGET, start)
318 .expect("the baseline is captured");
319 assert_eq!(
320 d.verdict,
321 DriftVerdict::OutsideHeap,
322 "a term at {outside} bytes flipped inside the hysteresis band"
323 );
324 }
325
326 let released = BUDGET * RELEASE_PCT / 100 - MIB;
328 let d = t
329 .sample_at(rss_at(released), 400 * MIB, BUDGET, start)
330 .expect("the baseline is captured");
331 assert_eq!(d.verdict, DriftVerdict::Settled);
332 }
333
334 #[test]
337 fn shrinking_is_reported_but_never_read_as_drift() {
338 let start = Instant::now();
339 let mut t = settled(2000 * MIB, 400 * MIB, start);
340 let d = t
341 .sample_at(1500 * MIB, 300 * MIB, BUDGET, start)
342 .expect("the baseline is captured");
343 assert_eq!(d.verdict, DriftVerdict::Settled);
344 assert_eq!(d.heap_growth_bytes, -((100 * MIB) as i64));
345 assert_eq!(d.outside_heap_growth_bytes, -((400 * MIB) as i64));
346 }
347
348 #[test]
352 fn a_climbing_startup_does_not_become_the_baseline() {
353 let start = Instant::now();
354 let mut t = DriftTracker::default();
355 let mut rss = 100 * MIB;
357 for _ in 0..20 {
358 assert_eq!(t.sample_at(rss, 50 * MIB, BUDGET, start), None);
359 rss += rss / 10;
360 }
361 assert!(t.baseline.is_none(), "a climbing session has not settled");
362
363 for _ in 0..=SETTLE_STREAK {
367 assert_eq!(t.sample_at(rss, 50 * MIB, BUDGET, start), None);
368 }
369 assert!(t.baseline.is_some(), "a flat run settles the baseline");
370 assert!(t.sample_at(rss, 50 * MIB, BUDGET, start).is_some());
371 }
372
373 #[test]
378 fn one_flat_sample_inside_a_climb_does_not_settle_it() {
379 let start = Instant::now();
380 let mut t = DriftTracker::default();
381 let mut rss = 100 * MIB;
382 for _ in 0..10 {
383 t.sample_at(rss, 50 * MIB, BUDGET, start);
385 t.sample_at(rss, 50 * MIB, BUDGET, start);
386 rss += rss / 4;
387 t.sample_at(rss, 50 * MIB, BUDGET, start);
388 }
389 assert!(
390 t.baseline.is_none(),
391 "a climb interrupted by single flat samples is not settled"
392 );
393 }
394
395 #[test]
398 fn a_session_that_never_settles_captures_a_baseline_at_the_deadline() {
399 let start = Instant::now();
400 let mut t = DriftTracker::default();
401 let mut rss = 100 * MIB;
402 for _ in 0..SETTLE_DEADLINE_SAMPLES {
403 t.sample_at(rss, 50 * MIB, BUDGET, start);
404 rss += rss / 10;
405 }
406 assert!(
407 t.baseline.is_some(),
408 "the deadline captures a baseline even while RSS climbs"
409 );
410 }
411
412 #[test]
415 fn a_zero_budget_reports_movement_without_a_verdict() {
416 let start = Instant::now();
417 let mut t = DriftTracker::default();
418 for _ in 0..=SETTLE_STREAK {
419 t.sample_at(2000 * MIB, 400 * MIB, 0, start);
420 }
421 let d = t
422 .sample_at(9000 * MIB, 400 * MIB, 0, start)
423 .expect("the baseline is captured");
424 assert_eq!(d.verdict, DriftVerdict::Settled);
425 assert_eq!(d.outside_heap_growth_bytes, (7000 * MIB) as i64);
426 }
427
428 #[test]
429 fn every_verdict_has_a_label() {
430 for v in [
431 DriftVerdict::Settled,
432 DriftVerdict::Heap,
433 DriftVerdict::OutsideHeap,
434 DriftVerdict::Both,
435 ] {
436 assert!(!v.label().is_empty());
437 }
438 }
439}