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