Skip to main content

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}