Skip to main content

mz_compute/compute_state/
peek_offload.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5
6//! Driving an index peek's walk away from the timely worker that owns it.
7//!
8//! An offloaded walk steps its scan on the blocking pool, so neither the timely worker nor an
9//! async one carries it. It returns to its async task only to write a batch or to answer, and
10//! checks for cancellation every `yield_granularity` positions in between. The scan and the permit
11//! that admitted it travel together, and every way the walk ends, a panic included, drops the two
12//! together.
13//!
14//! The permit bounds the walks that run, not the walks that exist: an unadmitted walk queues
15//! holding its scan, which pins the batches its cursors were opened over, so retained memory grows
16//! with offloaded walks rather than running ones.
17//!
18//! This driver performs the only IO, so the scan stays free of async colouring. A walk
19//! whose rows outgrow an inline answer hands over a full batch, the driver writes it to the peek
20//! stash, and the walk carries on from where it stopped.
21
22use std::sync::{Arc, Mutex};
23use std::thread::Thread;
24use std::time::{Duration, Instant};
25
26use mz_compute_client::protocol::command::Peek;
27use mz_compute_client::protocol::response::{PeekError, PeekResponse};
28use mz_compute_types::dyncfgs::{
29    INDEX_PEEK_PERMIT_FRACTION, INDEX_PEEK_YIELD_GRANULARITY, PEEK_RESPONSE_STASH_BATCH_MAX_RUNS,
30};
31use mz_dyncfg::{ConfigSet, ConfigValHandle};
32use mz_expr::ColumnOrder;
33use mz_ore::cast::CastLossy;
34use mz_ore::soft_panic_or_log;
35use mz_ore::task::AbortOnDropHandle;
36use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot};
37use tracing::{debug, warn};
38use uuid::Uuid;
39
40use crate::compute_state::PeekRowIterationConfig;
41use crate::compute_state::peek_metrics::PeekWalkMetrics;
42use crate::compute_state::peek_scan::{IndexPeekScan, RowBatch, ScanOutcome, rows_response};
43use crate::compute_state::peek_stash::{StashTarget, StashUpload};
44
45/// The bound on how many offloaded peek walks run at once.
46///
47/// The resource it protects is CPU rather than any one worker's thread, so one instance covers
48/// every worker that shares it.
49pub struct PeekPermits {
50    semaphore: Arc<Semaphore>,
51    /// Under one lock, so a resize computing its delta and a release deciding whether to return
52    /// its permit see one state.
53    bound: Mutex<Bound>,
54    /// The workers the configured fraction is a fraction of.
55    workers: usize,
56}
57
58/// How many permits the semaphore has issued, and how many it should have.
59struct Bound {
60    /// Permits issued and not forgotten: the ones the semaphore holds plus the ones walks hold.
61    granted: usize,
62    /// What the last resize asked for. Below `granted` while a shrink is still being absorbed.
63    target: usize,
64}
65
66/// The permit an offloaded walk holds while it runs, released on drop.
67///
68/// A release owed to a shrink that found every permit held is forgotten rather than returned.
69/// That is how a lowered bound converges without interrupting a walk: a permit released while a
70/// walk is queued goes straight to that walk and never becomes available, so taking free permits
71/// back at the next resize would leave the bound where it was for as long as anything queued.
72pub(super) struct WalkPermit {
73    permit: Option<OwnedSemaphorePermit>,
74    permits: Arc<PeekPermits>,
75}
76
77impl Drop for WalkPermit {
78    fn drop(&mut self) {
79        let Some(permit) = self.permit.take() else {
80            return;
81        };
82        if self.permits.absorb_release() {
83            permit.forget();
84        }
85    }
86}
87
88impl PeekPermits {
89    /// Creates a bound over the `workers` a process runs, admitting one walk per worker until it
90    /// is configured otherwise.
91    pub fn new(workers: usize) -> Self {
92        let permits = Self::permits_for(workers, 1.0);
93        Self {
94            semaphore: Arc::new(Semaphore::new(permits)),
95            bound: Mutex::new(Bound {
96                granted: permits,
97                target: permits,
98            }),
99            workers,
100        }
101    }
102
103    /// Waits for a permit.
104    async fn acquire(self: &Arc<Self>) -> WalkPermit {
105        let permit = Arc::clone(&self.semaphore)
106            .acquire_owned()
107            .await
108            .expect("peek permits are never closed");
109        WalkPermit {
110            permit: Some(permit),
111            permits: Arc::clone(self),
112        }
113    }
114
115    /// Whether the permit about to be released is owed to a shrink, in which case the caller
116    /// forgets it instead of returning it.
117    fn absorb_release(&self) -> bool {
118        let mut bound = self.bound.lock().expect("lock poisoned");
119        if bound.granted > bound.target {
120            bound.granted -= 1;
121            true
122        } else {
123            false
124        }
125    }
126
127    /// The permits `fraction` asks for over `workers`, at least one and at most what a semaphore
128    /// can hold.
129    ///
130    /// A `fraction` that is negative or NaN lands on the floor of one rather than being rejected,
131    /// because a misconfigured bound should pace the offload rather than stop it.
132    fn permits_for(workers: usize, fraction: f64) -> usize {
133        let scaled = f64::cast_lossy(workers) * fraction;
134        if scaled < 1.0 || scaled.is_nan() {
135            return 1;
136        }
137        usize::cast_lossy(scaled).clamp(1, Semaphore::MAX_PERMITS)
138    }
139
140    /// Resizes the bound to what `fraction` asks for.
141    ///
142    /// Raising it takes effect at once. Lowering it interrupts no walk: the permits free right now
143    /// go, and the rest go as the walks holding them finish, see [`WalkPermit`].
144    fn resize(&self, fraction: f64) {
145        let target = Self::permits_for(self.workers, fraction);
146
147        let mut bound = self.bound.lock().expect("lock poisoned");
148        bound.target = target;
149        if target > bound.granted {
150            self.semaphore.add_permits(target - bound.granted);
151            bound.granted = target;
152        } else if target < bound.granted {
153            bound.granted -= self.semaphore.forget_permits(bound.granted - target);
154        }
155    }
156
157    /// The permits no walk holds.
158    #[cfg(test)]
159    fn available_permits(&self) -> usize {
160        self.semaphore.available_permits()
161    }
162
163    /// Takes a permit if one is free.
164    #[cfg(test)]
165    fn try_acquire(self: &Arc<Self>) -> Option<WalkPermit> {
166        let permit = Arc::clone(&self.semaphore).try_acquire_owned().ok()?;
167        Some(WalkPermit {
168            permit: Some(permit),
169            permits: Arc::clone(self),
170        })
171    }
172}
173
174/// The parameters an offloaded walk reads, each as a handle rather than a value.
175///
176/// A handle lets a configuration change reach a walk already under way without discarding the
177/// positions it has visited. The granularity and the row limit are read at every slice boundary,
178/// the batch runs where the walk opens its upload, and the permit fraction once on the worker.
179#[derive(Clone, Debug)]
180pub(super) struct OffloadConfig {
181    permit_fraction: ConfigValHandle<f64>,
182    yield_granularity: ConfigValHandle<usize>,
183    batch_max_runs: ConfigValHandle<usize>,
184    row_iteration: PeekRowIterationConfig,
185}
186
187impl OffloadConfig {
188    pub(super) fn new(config: &ConfigSet) -> Self {
189        Self {
190            permit_fraction: INDEX_PEEK_PERMIT_FRACTION.handle(config),
191            yield_granularity: INDEX_PEEK_YIELD_GRANULARITY.handle(config),
192            batch_max_runs: PEEK_RESPONSE_STASH_BATCH_MAX_RUNS.handle(config),
193            row_iteration: PeekRowIterationConfig::new(config),
194        }
195    }
196}
197
198/// An index peek whose walk is running away from the worker that owns it.
199///
200/// Note that `OffloadedPeek` intentionally does not implement or derive `Clone`, as each one is
201/// meant to be dropped once it has been responded to.
202pub struct OffloadedPeek {
203    pub(crate) peek: Peek,
204    /// The peek's answer, eventually.
205    pub(crate) result: oneshot::Receiver<(PeekResponse, Duration)>,
206    /// The `tracing::Span` tracking this peek's operation.
207    pub(crate) span: tracing::Span,
208    /// The task driving the walk. Dropping this aborts it. The blocking thread stepping the scan
209    /// stops at its next cancellation check, and the scan, the cursors it holds, and the permit
210    /// that admitted it drop there.
211    _abort_handle: AbortOnDropHandle<()>,
212}
213
214impl OffloadedPeek {
215    /// Offloads `scan` to a task that finishes the walk away from the worker, waking `worker`
216    /// once the outcome is ready.
217    ///
218    /// `stash` is where the walk writes rows the peek may not answer with inline. It is `Some`
219    /// exactly when `scan` was opened stash-eligible, so a scan that offers a batch always has a
220    /// target. Should it not, the walk fails the peek.
221    ///
222    /// The scan may already hold a full batch. This driver takes it, so offloading is how a peek
223    /// too large to answer inline reaches the stash.
224    pub(super) fn start(
225        peek: Peek,
226        scan: IndexPeekScan,
227        stash: Option<StashTarget>,
228        permits: Arc<PeekPermits>,
229        config: OffloadConfig,
230        metrics: PeekWalkMetrics,
231        worker: Thread,
232    ) -> Self {
233        let (mut result_tx, result_rx) = oneshot::channel();
234        permits.resize(config.permit_fraction.get());
235
236        let peek_uuid = peek.uuid;
237        // Shared rather than copied per use: the answer arm needs it owned, because it builds
238        // the response on the blocking pool, and a walk whose rows went to the stash never looks
239        // at it at all.
240        let order_by: Arc<[ColumnOrder]> = peek.finishing.order_by.as_slice().into();
241
242        let task_handle = mz_ore::task::spawn(
243            || format!("peek_offload::walk({peek_uuid})"),
244            async move {
245                // Wall clock from the hand-off rather than the walk's own time. The wait for a
246                // permit is in here, because that is what the peek's latency is made of.
247                let start = Instant::now();
248
249                let queued = metrics.queued_for_permit();
250                let permit = tokio::select! {
251                    permit = permits.acquire() => permit,
252                    // Cancellation while the walk waits its turn drops the receiving end of the
253                    // result channel. The scan leaves the queue with this task, releasing the
254                    // cursors and the accumulated rows it was holding, and never takes a permit.
255                    () = result_tx.closed() => return,
256                };
257                queued.admitted();
258
259                let state = WalkState {
260                    scan,
261                    _permit: permit,
262                    result_tx,
263                };
264                let (state, response) =
265                    Self::walk(state, peek_uuid, stash, &config, &metrics, order_by).await;
266                let Some(response) = response else {
267                    return;
268                };
269                let result_tx = state.result_tx;
270
271                // Past the walk rather than at the permit, so a walk that took a permit and was
272                // then cancelled is not counted, as `walked_offloaded` states.
273                metrics.walked_offloaded();
274                if matches!(response, PeekResponse::Stashed(_)) {
275                    metrics.walked_to_stash();
276                }
277
278                match result_tx.send((response, start.elapsed())) {
279                    Ok(()) => {}
280                    // TODO: a dropped stashed response leaves its parts in blob storage. The
281                    // upload's own cleanup cannot reach them, because a finished batch belongs to
282                    // the response rather than to the upload, and rebuilding a deletable batch
283                    // from what the response carries needs a `WriteHandle` this task does not
284                    // hold. A reader-side sweep or persist's own garbage collection covers it.
285                    Err((_response, elapsed)) => {
286                        debug!(duration = ?elapsed, "dropping result for cancelled peek {peek_uuid}")
287                    }
288                }
289
290                // Unparked rather than activated: the sweep polls this peek anyway, and a
291                // root-path activation would also mark the worker's dataflows schedulable. An
292                // unpark landing before the park is remembered, so the wake cannot be missed.
293                worker.unpark();
294            },
295        );
296
297        Self {
298            peek,
299            result: result_rx,
300            span: tracing::Span::current(),
301            _abort_handle: task_handle.abort_on_drop(),
302        }
303    }
304
305    /// Drives the scan in `state` to the peek's answer, writing what it may not answer with inline
306    /// to `stash`. `None` means the peek was cancelled, which is the one way the walk ends without
307    /// an answer.
308    async fn walk(
309        mut state: WalkState,
310        peek_uuid: Uuid,
311        stash: Option<StashTarget>,
312        config: &OffloadConfig,
313        metrics: &PeekWalkMetrics,
314        order_by: Arc<[ColumnOrder]>,
315    ) -> (WalkState, Option<PeekResponse>) {
316        // Opened by the first batch the scan hands over, so a walk that never crosses the stash
317        // threshold neither opens a shard nor writes a byte. Whether it is open is also what
318        // decides how the peek is answered: an upload answers with a handle, and no upload means
319        // every row the walk produced is still here to answer with.
320        let mut upload: Option<StashUpload> = None;
321
322        loop {
323            // Stepped on the blocking pool, because the walk is CPU-bound for its whole length and
324            // would otherwise hold an async worker there. Not `block_in_place`, which parks a core
325            // for the same span. The scan crosses to the pool and back, which it can because it
326            // owns `Arc`-backed batch snapshots and holds no trace handle.
327            let walk_config = config.clone();
328            let (stepped, outcome) = mz_ore::task::spawn_blocking(
329                || "peek_offload::walk",
330                move || state.step_until_blocked(&walk_config),
331            )
332            .await;
333            state = stepped;
334            let scan = &mut state.scan;
335
336            // A cancelled walk gives up its upload by dropping it, which deletes what it wrote,
337            // so this is an ordinary return.
338            let Some(outcome) = outcome else {
339                return (state, None);
340            };
341
342            // This driver finishes the walk the worker started, so it reports every phase of it,
343            // the slices that ran on the worker included. The worker reported none of them.
344            match outcome {
345                ScanOutcome::Finished(Ok(rows)) => {
346                    let phases = scan.phases();
347                    metrics.observe_error_phase(&phases);
348                    metrics.observe_ok_phase(&phases);
349                    let response = match upload {
350                        // Onto the blocking pool for the same reason the walk runs there:
351                        // building the answer sorts and copies the whole row set, and a
352                        // finishing that carries an order never reaches the stash, so it
353                        // accumulates the whole result before it can.
354                        None => {
355                            let order_by = Arc::clone(&order_by);
356                            let (response, elapsed) = mz_ore::task::spawn_blocking(
357                                || "peek_offload::answer",
358                                move || {
359                                    let start = Instant::now();
360                                    (rows_response(rows, &order_by), start.elapsed())
361                                },
362                            )
363                            .await;
364                            metrics.observe_row_collection(elapsed);
365                            response
366                        }
367                        Some(upload) => stashed_answer(peek_uuid, upload, rows).await,
368                    };
369                    return (state, Some(response));
370                }
371                ScanOutcome::Finished(Err(error)) => {
372                    metrics.observe_error_phase(&scan.phases());
373                    // The peek is answered with the error rather than with the rows written so
374                    // far, so nothing will ever read them, and the upload's drop deletes them.
375                    return (state, Some(PeekResponse::Error(error)));
376                }
377                ScanOutcome::Suspended => {
378                    // `step_until_blocked` returns a suspension only with a batch, and a scan that
379                    // has one makes no progress until it is taken.
380                    if let Some(batch) = scan.take_batch() {
381                        let Some(stash) = &stash else {
382                            // Only an eligible scan fills a batch, and eligibility is what gave
383                            // this walk its target, so this is a defect at the offload site.
384                            // Answered as well as logged, because the walk has stopped either way.
385                            soft_panic_or_log!(
386                                "offloaded walk holds a batch and has no stash target"
387                            );
388                            metrics.observe_error_phase(&scan.phases());
389                            return (
390                                state,
391                                Some(PeekResponse::Error(PeekError::unstructured(
392                                    "internal error: offloaded peek walk has nowhere to write its rows",
393                                ))),
394                            );
395                        };
396
397                        let open = match &mut upload {
398                            Some(open) => open,
399                            none => match stash.open(config.batch_max_runs.get()).await {
400                                Ok(opened) => none.insert(opened),
401                                Err(error) => {
402                                    warn!(%peek_uuid, %error, "peek stash failed to open a shard");
403                                    metrics.observe_error_phase(&scan.phases());
404                                    return (
405                                        state,
406                                        Some(PeekResponse::Error(PeekError::unstructured(
407                                            error.to_string(),
408                                        ))),
409                                    );
410                                }
411                            },
412                        };
413
414                        if let Err(error) = open.push(batch).await {
415                            // Persist rejects only a batch handed to it wrongly, so this is a
416                            // defect in the upload rather than a blip.
417                            warn!(%peek_uuid, %error, "peek stash rejected a batch");
418                            metrics.observe_error_phase(&scan.phases());
419                            return (
420                                state,
421                                Some(PeekResponse::Error(PeekError::unstructured(
422                                    error.to_string(),
423                                ))),
424                            );
425                        }
426                    }
427                }
428            }
429        }
430    }
431}
432
433/// What an offloaded walk carries between its async task and the blocking pool.
434///
435/// The three travel together so that an aborted task cannot separate them: the scan is stepped
436/// on a blocking thread that an abort cannot interrupt, and the permit accounts for that thread
437/// until the scan leaves it. Fields drop in declaration order, so the scan and its batches go
438/// before the permit that accounts for them.
439struct WalkState {
440    scan: IndexPeekScan,
441    _permit: WalkPermit,
442    /// The sending end of the peek's result channel. Its receiver is dropped by cancellation and
443    /// by nothing else, so a closed channel is the cancellation signal.
444    result_tx: oneshot::Sender<(PeekResponse, Duration)>,
445}
446
447impl WalkState {
448    /// Steps the scan until it ends, offers a batch, or the peek is cancelled, whichever comes
449    /// first. `None` is the cancellation.
450    ///
451    /// Runs on a blocking thread, and reads the configuration and checks for cancellation every
452    /// `yield_granularity` positions rather than returning to the async task, which costs a
453    /// round trip through the runtime that a walk with nothing to await has no use for.
454    fn step_until_blocked(mut self, config: &OffloadConfig) -> (Self, Option<ScanOutcome>) {
455        loop {
456            if self.result_tx.is_closed() {
457                return (self, None);
458            }
459
460            // A granularity of zero would spend no fuel, and a scan stepped with no fuel makes no
461            // progress, so the walk would spin without ever reaching an answer.
462            let mut fuel = config.yield_granularity.get().max(1);
463            let row_iteration_limit = config.row_iteration.current_limit();
464
465            match self.scan.step(row_iteration_limit, &mut fuel) {
466                // Out of fuel with nothing to hand over: a cancellation check, not a stop.
467                ScanOutcome::Suspended if !self.scan.batch_ready() => {}
468                outcome => return (self, Some(outcome)),
469            }
470        }
471    }
472}
473
474/// Writes `tail`, the rows the walk still held when it ended, to `upload`, finishes it, and
475/// builds the response that names the stashed batch.
476///
477/// The tail goes to the stash rather than beside the handle, so that a stashed answer carries no
478/// rows inline: the tail can hold up to the batch size, and the controller merges every worker's
479/// inline rows into one response that environmentd holds whole. It rides the flush the upload
480/// pays anyway.
481async fn stashed_answer(peek_uuid: Uuid, mut upload: StashUpload, tail: RowBatch) -> PeekResponse {
482    if !tail.is_empty() {
483        if let Err(error) = upload.push(tail).await {
484            warn!(%peek_uuid, %error, "peek stash rejected a batch");
485            return PeekResponse::Error(PeekError::unstructured(error.to_string()));
486        }
487    }
488    match upload.finish().await {
489        Ok(response) => response,
490        // A defect in the upload rather than a blip, like a rejected push. The parts stay behind,
491        // see `StashUpload::finish`.
492        Err(error) => {
493            warn!(%peek_uuid, %error, "peek stash failed to finish a batch");
494            PeekResponse::Error(PeekError::unstructured(error.to_string()))
495        }
496    }
497}
498
499#[cfg(test)]
500mod tests;