Skip to main content

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}