memory_budget/budget/snapshot.rs
1//! Diagnostic snapshots emitted by the budget tick.
2
3use crate::jemalloc::JemallocStats;
4use crate::policy::Verdict;
5
6/// Diagnostic snapshot of one budget tick.
7#[derive(Clone, Debug)]
8pub struct BudgetSnapshot {
9 /// Configured target RSS in bytes.
10 pub target_bytes: u64,
11 /// Target after active-lease subtraction.
12 pub effective_target_bytes: u64,
13 /// **Effective** RSS the policy used this tick. When jemalloc
14 /// stats are present this is `jemalloc.resident_bytes` (the true
15 /// resident footprint); otherwise it falls back to the sysinfo
16 /// reading in [`BudgetSnapshot::rss_sysinfo_bytes`].
17 ///
18 /// On some platforms, the operating-system reading can diverge
19 /// materially from the allocator's resident estimate. Driving the
20 /// policy off `jemalloc.resident` keeps grow/shrink decisions aligned
21 /// with the allocator when those statistics are available.
22 pub rss_bytes: u64,
23 /// Raw sysinfo RSS reading (`phys_footprint` on macOS, RSS on
24 /// Linux). Kept for the gap diagnostic — compare against
25 /// [`BudgetSnapshot::rss_bytes`] to see how far the OS-reported
26 /// footprint diverges from jemalloc's view. Equals `rss_bytes`
27 /// when no jemalloc stats are available.
28 pub rss_sysinfo_bytes: u64,
29 /// Sum of bytes reserved by live [`LeaseGuard`](crate::LeaseGuard)s.
30 pub lease_bytes_active: u64,
31 /// jemalloc introspection at tick time. Splits the non-cache
32 /// bucket into "live allocations the program holds"
33 /// ([`JemallocStats::allocated_bytes`]) and "pages jemalloc has
34 /// not yet returned to the kernel"
35 /// ([`JemallocStats::retained_bytes`]). Zero on builds that do
36 /// not install jemalloc.
37 pub jemalloc: JemallocStats,
38 /// What the policy decided this tick and why.
39 pub verdict: Verdict,
40 /// Number of consecutive ticks (including this one) that have
41 /// ended in the same [`Verdict`] label. Resets to `1` when the
42 /// label changes. Useful for spotting wedges in post-mortem
43 /// logs ("the budget has been at `skip-shrink-lean-caches` for
44 /// 480 ticks") without arithmetic on timestamps.
45 pub verdict_streak: u32,
46 /// When the verdict label changed at this tick, the label of
47 /// the previous run; `None` otherwise. Lets post-mortem capture
48 /// the length of the run that just ended via
49 /// [`BudgetSnapshot::previous_verdict_streak`].
50 pub previous_verdict_label: Option<&'static str>,
51 /// Length of the run that just ended (`Some` when
52 /// [`BudgetSnapshot::previous_verdict_label`] is `Some`).
53 pub previous_verdict_streak: Option<u32>,
54 /// One entry per still-live registered cache.
55 pub caches: Vec<CacheSnapshot>,
56 /// One entry per still-live registered [`NonCacheReporter`]
57 /// (e.g. the reasoner working-set gauge). Names slices of the
58 /// otherwise-opaque `non_cache_live` bucket.
59 ///
60 /// [`NonCacheReporter`]: crate::NonCacheReporter
61 pub reporters: Vec<ReporterSnapshot>,
62}
63
64impl BudgetSnapshot {
65 /// Sum of `new_max_bytes` across every cache after the tick.
66 /// Useful when logging "what did the budget reapportion to".
67 #[must_use]
68 pub fn cap_bytes_after(&self) -> u64 {
69 self.caches.iter().map(|c| c.new_max_bytes).sum()
70 }
71
72 /// Sum of `current_bytes` across every cache at tick time —
73 /// i.e. live cache footprint, before the new cap is applied.
74 #[must_use]
75 pub fn cache_bytes_current(&self) -> u64 {
76 self.caches.iter().map(|c| c.current_bytes).sum()
77 }
78
79 /// `rss_bytes − cache_bytes_current − lease_bytes_active`,
80 /// saturating at zero. This is the **non-cache bucket**: heap
81 /// that the budget knows about but cannot resize — typically
82 /// transient allocator state (parser context, query result buffers,
83 /// or other working memory) plus the
84 /// non-reclaimable parts of jemalloc's retained-decommitted
85 /// pages.
86 ///
87 /// Surfaced on every tick emission as `non_cache_bytes` so
88 /// post-mortem analysis can attribute RSS growth to a specific
89 /// bucket (cache pressure vs. non-cache spike) instead of
90 /// inferring it from the cache breakdown. See
91 /// [`BudgetSnapshot::non_cache_live_bytes`] for the further
92 /// jemalloc-attributed split.
93 #[must_use]
94 pub fn non_cache_bytes(&self) -> u64 {
95 self.rss_bytes
96 .saturating_sub(self.cache_bytes_current())
97 .saturating_sub(self.lease_bytes_active)
98 }
99
100 /// Non-cache bytes attributed to **live application
101 /// allocations** by jemalloc: `je_allocated_bytes −
102 /// cache_bytes_current − lease_bytes_active`, saturating at
103 /// zero.
104 ///
105 /// Reading this alongside `non_cache_bytes` is the diagnostic
106 /// that separates real leaks from jemalloc retention. If
107 /// `non_cache_live_bytes` is flat across a long-running soak but
108 /// `non_cache_bytes` keeps growing, the growth is jemalloc
109 /// retained pages (`jemalloc.retained_bytes`) — an allocator
110 /// tuning issue. If `non_cache_live_bytes` itself grows, the
111 /// program is holding more memory (a leak somewhere outside
112 /// the registered caches).
113 ///
114 /// Zero on builds that do not install jemalloc.
115 #[must_use]
116 pub fn non_cache_live_bytes(&self) -> u64 {
117 self.jemalloc
118 .allocated_bytes
119 .saturating_sub(self.cache_bytes_current())
120 .saturating_sub(self.lease_bytes_active)
121 }
122
123 /// Sum of `current_bytes` across all live non-cache reporters —
124 /// the **attributed** portion of `non_cache_live_bytes`.
125 #[must_use]
126 pub fn reporter_bytes(&self) -> u64 {
127 self.reporters
128 .iter()
129 .map(|r| r.current_bytes)
130 .fold(0u64, u64::saturating_add)
131 }
132
133 /// `non_cache_live_bytes − reporter_bytes`, saturating at zero —
134 /// the **unattributed** remainder of the live non-cache bucket
135 /// (parser context, query buffers, transient allocator state, and
136 /// any subsystem that hasn't registered a reporter yet).
137 #[must_use]
138 pub fn non_cache_other_bytes(&self) -> u64 {
139 self.non_cache_live_bytes()
140 .saturating_sub(self.reporter_bytes())
141 }
142}
143
144/// Per-reporter slice of a [`BudgetSnapshot`].
145#[derive(Clone, Debug)]
146pub struct ReporterSnapshot {
147 /// Reporter name (`NonCacheReporter::name`).
148 pub name: String,
149 /// Bytes the reporter said it was holding when the tick fired.
150 pub current_bytes: u64,
151}
152
153/// Per-cache slice of a [`BudgetSnapshot`].
154#[derive(Clone, Debug)]
155pub struct CacheSnapshot {
156 /// Cache name (`Resizable::name`).
157 pub name: String,
158 /// Bytes currently held by the cache when the tick fired.
159 pub current_bytes: u64,
160 /// Cap before this tick reapportioned.
161 pub previous_max_bytes: u64,
162 /// Cap installed by this tick.
163 pub new_max_bytes: u64,
164 /// Most recent hit rate; `None` if no lookups yet.
165 pub hit_rate: Option<f64>,
166}
167
168impl CacheSnapshot {
169 /// Signed cap change this tick (`new_max_bytes − previous_max_bytes`).
170 /// Negative on shrink, positive on grow, zero on hold or skip.
171 ///
172 /// Logged as `delta_bytes` on each per-cache emission so
173 /// post-mortem analysis can grep for nonzero deltas directly
174 /// without doing arithmetic.
175 ///
176 /// Returns `i64::MAX` / `i64::MIN` on extreme cases where the
177 /// difference would not fit in `i64` — practically unreachable
178 /// (caps are bounded by `MEMORY_BUDGET_TARGET_MIB`, not `u64::MAX`)
179 /// but the saturation keeps the math total.
180 #[must_use]
181 pub fn delta_bytes(&self) -> i64 {
182 let prev = i64::try_from(self.previous_max_bytes).unwrap_or(i64::MAX);
183 let new = i64::try_from(self.new_max_bytes).unwrap_or(i64::MAX);
184 new.saturating_sub(prev)
185 }
186}