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;