memory_budget/budget/memory/handle.rs
1//! The [`MemoryBudget`] handle and its construction APIs.
2
3use std::sync::atomic::AtomicU64;
4use std::sync::{Arc, Mutex};
5
6use super::super::state::{BudgetState, Registration, VerdictStreak};
7use crate::config::BudgetConfig;
8use crate::jemalloc::{JemallocStatsSource, NoJemallocStats};
9use crate::policy::{Policy, ProportionalPolicy};
10use crate::reporter::NonCacheReporter;
11use crate::resizable::Resizable;
12use crate::rss::{RssSource, SystemRss};
13
14/// Process-wide coordinator that periodically reapportions cache
15/// capacities so the application stays close to its target RSS.
16///
17/// Construct via [`MemoryBudget::new`] or [`MemoryBudget::from_env`].
18/// Register every cache via [`MemoryBudget::register`]. Start the
19/// background tick via [`MemoryBudget::spawn`].
20pub struct MemoryBudget {
21 pub(super) state: Arc<BudgetState>,
22}
23
24impl MemoryBudget {
25 /// Construct a budget from an explicit config and the default RSS
26 /// source and policy. Caller is responsible for spawning the
27 /// background ticker via [`MemoryBudget::spawn`].
28 #[must_use]
29 pub fn new(config: BudgetConfig) -> Self {
30 Self::with_rss(config, Box::new(SystemRss::new()))
31 }
32
33 /// Convenience constructor that resolves config from environment
34 /// using the system RAM reported by the default [`SystemRss`].
35 #[must_use]
36 pub fn from_env() -> Self {
37 let rss = SystemRss::new();
38 let config = BudgetConfig::from_env(Some(rss.total_ram_bytes()));
39 Self::with_rss(config, Box::new(rss))
40 }
41
42 /// One-call bootstrap for an application using jemalloc. Builds a budget from
43 /// environment, wires
44 /// [`SystemJemallocStats`](crate::SystemJemallocStats), failing if
45 /// mallctl initialisation is unavailable, spawns the background ticker, and
46 /// emits a one-line `INFO memory_budget::budget` "initialised"
47 /// breadcrumb.
48 ///
49 /// Returns the budget `Arc` so the caller can register caches
50 /// later. The spawned ticker holds its own `Arc<BudgetState>`,
51 /// so the returned `Arc` may be dropped immediately by binaries
52 /// that have no caches to register yet — the tick keeps running.
53 ///
54 /// Only available with the `jemalloc` feature, because that's
55 /// the entire point of this constructor; binaries that don't
56 /// install jemalloc as their global allocator should build a
57 /// budget by hand via [`MemoryBudget::from_env`].
58 #[cfg(feature = "jemalloc")]
59 #[must_use]
60 pub fn start() -> Arc<Self> {
61 // Enable jemalloc's background decay thread at runtime rather
62 // than baking `background_thread:true` into MALLOC_CONF: macOS
63 // jemalloc lacks pthread background-thread support, so baking
64 // it makes every binary print "<jemalloc>: option
65 // background_thread currently supports pthread only" and does
66 // nothing. The runtime mallctl write succeeds on Linux (the
67 // production target, where it converges idle RSS) and returns
68 // an error on macOS, which we log at debug and ignore — no
69 // startup warning either way.
70 match tikv_jemalloc_ctl::background_thread::write(true) {
71 Ok(()) => tracing::debug!(
72 target: "memory_budget::budget",
73 "jemalloc background_thread enabled"
74 ),
75 Err(e) => tracing::debug!(
76 target: "memory_budget::budget",
77 error = %e,
78 "jemalloc background_thread not enabled (unsupported on this platform)"
79 ),
80 }
81 let mut budget = Self::from_env();
82 let stats = crate::SystemJemallocStats::new()
83 .expect("jemalloc stats are unavailable despite the jemalloc feature");
84 budget = budget.with_jemalloc_stats(Box::new(stats));
85 #[cfg(feature = "heap-profiling")]
86 {
87 budget = budget.with_heap_profiler_from_env();
88 }
89 let budget = Arc::new(budget);
90 // JoinHandle dropped on purpose: tokio keeps the task
91 // scheduled, and the spawned closure holds an
92 // `Arc<BudgetState>` so the budget survives the drop.
93 let _tick = budget.spawn();
94 tracing::info!(
95 target: "memory_budget::budget",
96 target_bytes = budget.target_bytes(),
97 tick_interval_secs = budget.tick_interval().as_secs(),
98 hard_ceiling_bytes = budget.hard_ceiling_bytes().unwrap_or(0),
99 "memory budget initialised"
100 );
101 budget
102 }
103
104 /// Non-jemalloc fallback for builds that don't link tikv-jemallocator
105 /// (e.g. Windows-msvc, where the tikv-jemalloc-sys build needs an
106 /// MSYS2/autoconf toolchain we don't provide). Builds a budget with
107 /// the default RSS source, spawns the ticker, and emits the same
108 /// `initialised` breadcrumb so application startup logs stay consistent
109 /// across platforms. The jemalloc-stats columns will read zero.
110 #[cfg(not(feature = "jemalloc"))]
111 #[must_use]
112 pub fn start() -> Arc<Self> {
113 let budget = Arc::new(Self::from_env());
114 let _tick = budget.spawn();
115 tracing::info!(
116 target: "memory_budget::budget",
117 target_bytes = budget.target_bytes(),
118 tick_interval_secs = budget.tick_interval().as_secs(),
119 hard_ceiling_bytes = budget.hard_ceiling_bytes().unwrap_or(0),
120 "memory budget initialised (no jemalloc stats — feature disabled)"
121 );
122 budget
123 }
124
125 /// Construct with a custom [`RssSource`] (used by tests).
126 #[must_use]
127 pub fn with_rss(config: BudgetConfig, rss: Box<dyn RssSource>) -> Self {
128 Self {
129 state: Arc::new(BudgetState {
130 config,
131 registrations: Mutex::new(Vec::new()),
132 active_lease_bytes: AtomicU64::new(0),
133 previous_cache_bytes: AtomicU64::new(0),
134 previous_cache_count: AtomicU64::new(0),
135 previous_rss_bytes: AtomicU64::new(0),
136 verdict_streak: Mutex::new(VerdictStreak::default()),
137 policy: Box::new(ProportionalPolicy),
138 rss,
139 jemalloc: Box::new(NoJemallocStats),
140 reporters: Mutex::new(Vec::new()),
141 #[cfg(feature = "heap-profiling")]
142 heap_profiler: None,
143 }),
144 }
145 }
146
147 /// Arm the [`HeapProfiler`](crate::heap_profile::HeapProfiler) from
148 /// the environment so the tick dumps a jemalloc heap profile each
149 /// time resident RSS crosses the next threshold. No-op when
150 /// `MEMORY_BUDGET_HEAP_PROFILE_DIR` is unset. Must run before any
151 /// registration or spawn.
152 ///
153 /// # Panics
154 ///
155 /// Panics if the budget state has already been shared (registered
156 /// or spawned).
157 #[cfg(feature = "heap-profiling")]
158 #[must_use]
159 fn with_heap_profiler_from_env(mut self) -> Self {
160 let state = Arc::get_mut(&mut self.state)
161 .expect("with_heap_profiler_from_env must be called before any registration or spawn");
162 state.heap_profiler = crate::heap_profile::HeapProfiler::from_env();
163 self
164 }
165
166 /// Install a [`JemallocStatsSource`] so the budget tick can split
167 /// the non-cache bucket into "live allocations" vs
168 /// "retained-by-jemalloc pages". Must be called before any cache
169 /// is registered.
170 ///
171 /// # Panics
172 ///
173 /// Panics if any cache is already registered, or if the budget's
174 /// internal state has been cloned (e.g. the ticker is already
175 /// spawned).
176 #[must_use]
177 pub fn with_jemalloc_stats(mut self, source: Box<dyn JemallocStatsSource>) -> Self {
178 let state = Arc::get_mut(&mut self.state)
179 .expect("with_jemalloc_stats must be called before any registration or spawn");
180 assert!(
181 state
182 .registrations
183 .lock()
184 .expect("registrations lock")
185 .is_empty(),
186 "with_jemalloc_stats must be called before any registration"
187 );
188 state.jemalloc = source;
189 self
190 }
191
192 /// Replace the default policy. Mostly used in tests; production
193 /// stays on [`ProportionalPolicy`] until benchmark data argues
194 /// otherwise.
195 ///
196 /// # Panics
197 ///
198 /// Panics if any cache is already registered — the new policy
199 /// must be installed before registration so that caches see a
200 /// consistent reapportionment regime.
201 #[must_use]
202 pub fn with_policy(mut self, policy: Box<dyn Policy>) -> Self {
203 let state = Arc::get_mut(&mut self.state)
204 .expect("with_policy must be called before any registration or spawn");
205 assert!(
206 state
207 .registrations
208 .lock()
209 .expect("registrations lock")
210 .is_empty(),
211 "with_policy must be called before any registration"
212 );
213 state.policy = policy;
214 self
215 }
216
217 /// Register a cache. The cache's current `max_bytes` is captured
218 /// as its preferred ceiling — the policy will never grow a cache
219 /// above this value.
220 ///
221 /// # Panics
222 ///
223 /// Panics if the internal registrations lock is poisoned.
224 pub fn register(&self, cache: &Arc<dyn Resizable>) {
225 let preferred_max_bytes = cache.max_bytes();
226 let mut guard = self.state.registrations.lock().expect("registrations lock");
227 guard.push(Registration {
228 cache: Arc::downgrade(cache),
229 preferred_max_bytes,
230 });
231 }
232
233 /// Register a read-only [`NonCacheReporter`] so the tick can
234 /// attribute a named slice of the `non_cache_live` bucket to it.
235 /// The budget holds a `Weak`, so dropping the reporter prunes it
236 /// from the next tick. Unlike caches, reporters are never resized.
237 ///
238 /// # Panics
239 ///
240 /// Panics if the internal reporters lock is poisoned.
241 pub fn register_reporter(&self, reporter: &Arc<dyn NonCacheReporter>) {
242 let mut guard = self.state.reporters.lock().expect("reporters lock");
243 guard.push(Arc::downgrade(reporter));
244 }
245}