mz_timely_util/pool_config.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Process-wide installation and configuration point for the buffer pool
17//! that backs chunk spilling.
18//!
19//! Chunk spill consumers, arriving with the chunk-batcher work, resolve their
20//! backing through [`active_pool`] at each spill decision, so live
21//! reconfiguration takes effect on the next call.
22//! The pool itself ([`mz_ore::pool::Pool`]) is a process singleton: live
23//! chunk handles keep their `Arc` into it, so replacing it on reconfigure
24//! would split residency accounting across two budgets. Operator-driven
25//! tunes instead retune the one instance in place via [`apply_pool_config`].
26//!
27//! See `doc/developer/design/20260610_buffer_managed_state.md` for the
28//! buffer-pool design.
29
30#![deny(missing_docs)]
31
32use mz_ore::cast::CastLossy;
33
34pub mod metrics;
35
36use mz_ore::cast::CastFrom;
37pub use mz_ore::pool::ExtentBackend;
38
39/// Process-wide buffer pool shared by every spill consumer in the process.
40///
41/// Construction reserves virtual address space only: 1 TiB per chunk size
42/// class plus 1 TiB per extent-arena class, tens of TiB in total. Physical
43/// memory is paid per resident chunk. On the rare platforms or
44/// configurations where the reservation fails, the pool is permanently
45/// unavailable for this process and [`apply_pool_config`] reports that by
46/// returning `false`.
47static GLOBAL_POOL: std::sync::OnceLock<Option<mz_ore::pool::Pool>> = std::sync::OnceLock::new();
48
49/// Whether [`apply_pool_config`] has installed the pool as the process's
50/// spill mechanism. [`active_pool`] reads this so consumers stay inert until
51/// the first config apply budgets the pool.
52static POOL_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
53
54/// Whether the pool was asked for a file backend and installed on swap.
55static FILE_FALLBACK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
56
57/// Whether [`apply_pool_config`] was asked for a file backend and installed
58/// the pool on swap instead. Covers installation only: a store that later
59/// disables writes on an I/O error reports through
60/// `mz_column_pool_extent_file_write_errors_total`.
61pub fn file_backend_fell_back() -> bool {
62 FILE_FALLBACK.load(std::sync::atomic::Ordering::Relaxed)
63}
64
65/// Returns the process-wide buffer pool only if something already
66/// initialized it; never triggers the virtual reservation itself. Metrics
67/// scrapes read through this so that observing a process (which may never
68/// install the pool) does not mmap the pool's address space as a side
69/// effect.
70pub fn global_pool_peek() -> Option<mz_ore::pool::Pool> {
71 GLOBAL_POOL.get().cloned().flatten()
72}
73
74/// Returns the pool only when [`apply_pool_config`] has installed and
75/// budgeted it, and never initializes anything itself. Chunk spill consumers
76/// resolve their backing through this at each spill decision, so an
77/// unconfigured or gated-off process keeps every chunk resident.
78pub fn active_pool() -> Option<mz_ore::pool::Pool> {
79 if POOL_MODE.load(std::sync::atomic::Ordering::Relaxed) {
80 global_pool_peek()
81 } else {
82 None
83 }
84}
85
86/// Builds the pool for `backend`, creating a file backend's directory. A
87/// file store that cannot be created or opened falls back to swap, which
88/// needs no scratch space. This is the only place that decides the fallback.
89fn build_pool(backend: ExtentBackend) -> Option<mz_ore::pool::Pool> {
90 // Logged with the installed backend, so a swap pool tells "file never
91 // requested" apart from a fallback.
92 let requested_file = matches!(backend, ExtentBackend::File { .. });
93 let built = match &backend {
94 ExtentBackend::Swap => mz_ore::pool::Pool::new(),
95 ExtentBackend::File { dir, .. } => std::fs::create_dir_all(dir)
96 .and_then(|()| mz_ore::pool::Pool::with_backend(backend.clone()))
97 .or_else(|err| {
98 // The error can also come from the pool's own reservation,
99 // which the swap attempt then reports again.
100 tracing::warn!(%err, ?dir, "buffer pool with a file store failed, trying swap");
101 let pool = mz_ore::pool::Pool::new();
102 if pool.is_ok() {
103 FILE_FALLBACK.store(true, std::sync::atomic::Ordering::Relaxed);
104 }
105 pool
106 }),
107 };
108 match built {
109 Ok(pool) => {
110 tracing::info!(
111 requested_file,
112 backend = ?pool.backend_kind(),
113 "buffer pool installed",
114 );
115 Some(pool)
116 }
117 Err(err) => {
118 tracing::warn!(
119 %err,
120 "buffer pool reservation failed; pool spilling unavailable",
121 );
122 None
123 }
124 }
125}
126
127/// Applies a buffer-pool configuration, installing the pool as the process's
128/// spill mechanism. This is the only path that constructs the pool. A process
129/// that never calls it, because the spill gate is off or it is unconfigured,
130/// never reserves the pool's address space or spawns its spill threads.
131/// Returns `false` (and changes nothing) if the pool is unavailable because
132/// its virtual reservation failed.
133///
134/// The first call installs the pool, and its `backend` fixes the extent store
135/// for the process. Later calls only retune the installed pool, so a changed
136/// backend, or a flag or scratch directory that feeds it, has no effect.
137///
138/// On success the pool becomes reachable through [`active_pool`] and its
139/// resident budget is retuned in place so live handles stay coherent.
140pub fn apply_pool_config(cfg: PoolPagerConfig, backend: ExtentBackend) -> bool {
141 let applied = apply_to(&GLOBAL_POOL, cfg, backend);
142 if applied {
143 POOL_MODE.store(true, std::sync::atomic::Ordering::Relaxed);
144 }
145 applied
146}
147
148/// [`apply_pool_config`] over an explicit slot, so tests can observe the
149/// install-once behavior without racing on the process-wide pool.
150fn apply_to(
151 slot: &std::sync::OnceLock<Option<mz_ore::pool::Pool>>,
152 cfg: PoolPagerConfig,
153 backend: ExtentBackend,
154) -> bool {
155 let Some(pool) = slot.get_or_init(|| build_pool(backend)) else {
156 return false;
157 };
158 pool.set_budget(cfg.budget_bytes);
159 pool.set_rss_target(cfg.rss_target_bytes);
160 pool.set_spill_threads(cfg.spill_threads);
161 pool.set_eager_backing(cfg.eager_backing);
162 pool.set_file_release(
163 cfg.file_release_interval,
164 u64::cast_from(cfg.file_release_bytes),
165 );
166 true
167}
168
169/// Inputs to [`apply_pool_config`]. All sizes are absolute bytes; fractions
170/// are resolved by the caller against *physical RAM* (see
171/// `mz_ore::memory::physical_memory_bytes`), never against an announced
172/// limit that may include swap.
173#[derive(Clone, Copy, Debug)]
174pub struct PoolPagerConfig {
175 /// Resident-bytes budget for uncompressed slots.
176 pub budget_bytes: usize,
177 /// Spill threads for off-worker eviction I/O (spawn-once).
178 pub spill_threads: usize,
179 /// Whether idle spill threads eagerly compress chunks to
180 /// `BackedResident` ahead of pressure.
181 pub eager_backing: bool,
182 /// Ceiling on the pool's total RSS; the compressed-resident tier is the
183 /// headroom above the budget and warm cap. Zero collapses the tier.
184 pub rss_target_bytes: usize,
185 /// How often spill threads release free file store slots.
186 pub file_release_interval: std::time::Duration,
187 /// Bytes of free file store slots released per extent class and
188 /// interval. Zero disables the release.
189 pub file_release_bytes: usize,
190}
191
192/// Spill threads for a process with `workers` timely workers at `fraction`.
193///
194/// `floor(workers * fraction)` rounded down to a power of two, and at least
195/// one for any positive fraction. A fraction that is not positive yields
196/// zero, which evicts inline on the calling thread.
197pub fn spill_threads_for_workers(workers: usize, fraction: f64) -> usize {
198 if fraction.is_nan() || fraction <= 0.0 {
199 return 0;
200 }
201 let scaled = usize::cast_lossy((f64::cast_lossy(workers) * fraction).floor());
202 match scaled.checked_ilog2() {
203 Some(log) => 1 << log,
204 None => 1,
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[mz_ore::test]
213 fn spill_threads_for_workers_steps_by_powers_of_two() {
214 let at_half = [
215 (1, 1),
216 (2, 1),
217 (4, 2),
218 (6, 2),
219 (8, 4),
220 (12, 4),
221 (16, 8),
222 (24, 8),
223 (31, 8),
224 (62, 16),
225 ];
226 for (workers, threads) in at_half {
227 assert_eq!(
228 spill_threads_for_workers(workers, 0.5),
229 threads,
230 "{workers} workers"
231 );
232 }
233 assert_eq!(spill_threads_for_workers(62, 0.0), 0);
234 assert_eq!(spill_threads_for_workers(62, -1.0), 0);
235 assert_eq!(spill_threads_for_workers(62, f64::NAN), 0);
236 assert_eq!(spill_threads_for_workers(1, 0.01), 1);
237 }
238
239 const CONFIG: PoolPagerConfig = PoolPagerConfig {
240 budget_bytes: 1 << 30,
241 spill_threads: 0,
242 eager_backing: false,
243 rss_target_bytes: 0,
244 file_release_interval: std::time::Duration::ZERO,
245 file_release_bytes: 0,
246 };
247
248 /// `apply_pool_config` installs the pool: `active_pool` resolves to it
249 /// afterwards. Mutates process-global state, so it is the only test in
250 /// this module that observes `POOL_MODE`.
251 #[mz_ore::test]
252 #[cfg_attr(miri, ignore)] // unsupported operation: foreign function calls (mmap, madvise)
253 fn apply_pool_config_installs_pool() {
254 let ok = apply_pool_config(CONFIG, ExtentBackend::Swap);
255 assert!(ok, "pool reservation expected to succeed in tests");
256 assert!(active_pool().is_some());
257 assert!(global_pool_peek().is_some());
258 }
259
260 /// The first apply fixes the backend, and later applies never change
261 /// it or touch the directory they name. Uses its own slot because the
262 /// process-wide pool is installed by whichever test runs first.
263 #[mz_ore::test]
264 #[cfg_attr(miri, ignore)] // unsupported operation: foreign function calls (mmap, madvise)
265 fn first_apply_fixes_backend() {
266 let slot = std::sync::OnceLock::new();
267 let ok = apply_to(&slot, CONFIG, ExtentBackend::Swap);
268 assert!(ok, "pool reservation expected to succeed in tests");
269
270 let dir = tempfile::tempdir().expect("tempdir");
271 let pool_dir = dir.path().join("pool");
272 let ok = apply_to(
273 &slot,
274 CONFIG,
275 ExtentBackend::File {
276 dir: pool_dir.clone(),
277 capacity_bytes: None,
278 },
279 );
280 assert!(ok);
281 assert!(!pool_dir.exists(), "a later apply creates no directory");
282 let pool = slot.get().cloned().flatten().expect("installed");
283 assert_eq!(
284 pool.backend_kind(),
285 mz_ore::pool::BackendKind::Swap,
286 "the backend is fixed by the first application",
287 );
288 }
289
290 /// A file backend whose directory cannot be created installs a swap
291 /// pool.
292 #[mz_ore::test]
293 #[cfg_attr(miri, ignore)] // unsupported operation: foreign function calls (mmap, madvise)
294 fn unusable_file_backend_falls_back_to_swap() {
295 let file = tempfile::NamedTempFile::new().expect("tempfile");
296 let slot = std::sync::OnceLock::new();
297 let ok = apply_to(
298 &slot,
299 CONFIG,
300 ExtentBackend::File {
301 dir: file.path().join("pool"),
302 capacity_bytes: None,
303 },
304 );
305 assert!(ok, "the fallback installs a pool");
306 let pool = slot.get().cloned().flatten().expect("installed");
307 assert_eq!(pool.backend_kind(), mz_ore::pool::BackendKind::Swap);
308 assert!(file_backend_fell_back(), "the fallback is reported");
309 }
310}