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 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}