Skip to main content

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}