Skip to main content

mz_compute/compute_state/
peek_stash.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//! For eligible peeks, we send the result back via the peek stash (aka persist
7//! blob), instead of inline in `ComputeResponse`.
8
9use std::num::NonZeroU64;
10use std::sync::Arc;
11
12use mz_compute_client::protocol::command::Peek;
13use mz_compute_client::protocol::response::{PeekResponse, StashedPeekResponse};
14use mz_ore::task::RuntimeExt;
15use mz_persist::location::ExternalError;
16use mz_persist_client::Schemas;
17use mz_persist_client::batch::{Added, Batch, BatchBuilder};
18use mz_persist_client::cache::PersistClientCache;
19use mz_persist_client::error::InvalidUsage;
20use mz_persist_types::codec_impls::UnitSchema;
21use mz_persist_types::{PersistLocation, ShardId};
22use mz_repr::{RelationDesc, Timestamp};
23use mz_storage_types::sources::SourceData;
24use timely::progress::Antichain;
25use tokio::runtime::Handle;
26use tokio::sync::oneshot;
27use tracing::warn;
28use uuid::Uuid;
29
30use crate::compute_state::peek_scan::RowBatch;
31
32/// A failure that leaves an upload unable to answer the peek whose rows it holds.
33///
34/// The variant names the step that refused, and the driver reports it as the peek's error.
35#[derive(Debug, thiserror::Error)]
36pub(super) enum StashError {
37    /// The stash location did not open, so nothing was written.
38    #[error("peek stash could not open its persist location: {0}")]
39    OpenLocation(#[source] ExternalError),
40    /// Persist refused a row the upload handed it.
41    #[error("peek stash could not write a row: {0}")]
42    WriteRow(#[source] InvalidUsage<Timestamp>),
43    /// Persist refused to finish the batch, which takes with it every part already written.
44    #[error("peek stash could not finish its batch: {0}")]
45    FinishBatch(#[source] InvalidUsage<Timestamp>),
46    /// The task finishing the batch ended without delivering one, which only a runtime that is
47    /// going away can cause.
48    #[error("peek stash lost the task finishing its batch")]
49    LostFinishTask,
50}
51
52/// A peek's answer on its way to the peek stash, written to persist a batch of rows at a time.
53///
54/// The upload owns the IO the stash needs, so a walk that feeds it performs none: a driver that can
55/// await pushes the rows the walk produced and finishes the upload, and the walk itself neither
56/// opens a client nor writes a byte. That split keeps a walk drivable from a timely worker and from
57/// an async task alike.
58///
59/// The rows an upload is given are the rows it writes, in the order it is given them. An upload
60/// that does not reach a reader deletes what it can, whether it is dropped by a driver that will
61/// answer with something else or stopped part-way through finishing.
62/// [`StashUpload::abandon`] bounds what that reaches and what it costs.
63pub(super) struct StashUpload {
64    /// The description the stashed response reports, and the schema the batch is written under.
65    relation_desc: RelationDesc,
66    /// The shard the batch belongs to, derived from the peek's uuid so that a reader holding the
67    /// response can find it.
68    shard_id: ShardId,
69    /// The parts persist has taken so far. Taken by whichever of [`StashUpload::finish`] and
70    /// [`StashUpload::abandon`] gets there first, and a `None` says the parts are accounted for
71    /// and nothing is left to delete.
72    batch_builder: Option<BatchBuilder<SourceData, (), Timestamp, i64>>,
73    /// The upper the batch is finished at, one step beyond the timestamp every row is written at.
74    upper: Antichain<Timestamp>,
75    /// Rows written so far, counting a row with a diff of `n` as `n` rows, which is how the
76    /// finishing counts them.
77    num_rows: u64,
78    /// Whether persist has taken a part off this builder and into blob storage. Until it has,
79    /// everything the upload holds is in memory, so an abandoned upload drops its builder instead
80    /// of paying a write and a delete to reclaim nothing.
81    wrote_parts: bool,
82    /// The runtime an abandoned upload's deletion is spawned on, held rather than taken from the
83    /// ambient context because [`StashUpload::abandon`] runs where there may be none: a `Drop`
84    /// carries no runtime context of its own, and `Handle::current` panics without one.
85    runtime: Handle,
86}
87
88/// The `expect` message where the builder is taken: it is present until `finish` or `abandon`
89/// takes it, and neither runs twice.
90const BUILDER_TAKEN: &str = "an upload holds its builder until it is finished or abandoned";
91
92impl StashUpload {
93    /// Opens an upload for the peek `peek_uuid` identifies.
94    ///
95    /// Fails when the stash location does not open, in which case nothing has been written.
96    pub(super) async fn open(
97        persist_clients: &PersistClientCache,
98        persist_location: PersistLocation,
99        batch_max_runs: usize,
100        peek_uuid: Uuid,
101        relation_desc: RelationDesc,
102    ) -> Result<Self, StashError> {
103        let client = persist_clients
104            .open(persist_location)
105            .await
106            .map_err(StashError::OpenLocation)?;
107
108        let shard_id = format!("s{}", peek_uuid);
109        let shard_id = ShardId::try_from(shard_id).expect("can parse");
110        let write_schemas: Schemas<SourceData, ()> = Schemas {
111            id: None,
112            key: Arc::new(relation_desc.clone()),
113            val: Arc::new(UnitSchema),
114        };
115
116        let result_ts = Timestamp::default();
117        let lower = Antichain::from_elem(result_ts);
118        let upper = Antichain::from_elem(result_ts.step_forward());
119
120        // We have to use SourceData, which is a wrapper around a Result<Row,
121        // DataflowError>, because the bare columnar Row encoder doesn't support
122        // encoding rows with zero columns.
123        //
124        // TODO: We _could_ work around the above by teaching the bare columnar
125        // Row encoder about zero-column rows.
126        let batch_builder = client
127            .batch_builder::<SourceData, (), Timestamp, i64>(
128                shard_id,
129                write_schemas,
130                lower,
131                Some(batch_max_runs),
132            )
133            .await;
134
135        Ok(Self {
136            relation_desc,
137            shard_id,
138            batch_builder: Some(batch_builder),
139            upper,
140            num_rows: 0,
141            wrote_parts: false,
142            runtime: Handle::current(),
143        })
144    }
145
146    /// Writes `rows` to the stash.
147    ///
148    /// Every row given is written. Stopping where the peek's finishing has all it can use is the
149    /// scan's to decide, since it is the scan that holds the finishing and produces the rows.
150    ///
151    /// Fails where persist rejects the write, which leaves the upload unusable and the rows it
152    /// holds unanswerable.
153    pub(super) async fn push(&mut self, rows: RowBatch) -> Result<(), StashError> {
154        let batch_builder = self.batch_builder.as_mut().expect(BUILDER_TAKEN);
155
156        for (row, diff) in rows {
157            self.num_rows += u64::from(NonZeroU64::try_from(diff).expect("diff fits into u64"));
158            let diff: i64 = diff.into();
159
160            let added = batch_builder
161                .add(&SourceData(Ok(row)), &(), &Timestamp::default(), &diff)
162                .await
163                .map_err(StashError::WriteRow)?;
164            self.wrote_parts |= matches!(added, Added::RecordAndParts);
165        }
166
167        Ok(())
168    }
169
170    /// Finishes the batch and builds the response that names it.
171    ///
172    /// Every row of the answer is in the batch. The response's `inline_rows` are left for the
173    /// controller, which merges in the rows of workers whose share never reached the stash.
174    ///
175    /// Fails where persist rejects the batch, which takes the parts with it: persist keeps the
176    /// builder and hands back no handle to what it holds. Only a batch whose bounds do not admit
177    /// its own updates is refused, which this upload's fixed lower, upper and timestamp cannot
178    /// produce.
179    pub(super) async fn finish(mut self) -> Result<PeekResponse, StashError> {
180        let delivered = self.finish_batch().await?;
181        let batch = delivered.take();
182
183        let stashed_response = StashedPeekResponse {
184            num_rows_batches: self.num_rows,
185            encoded_size_bytes: batch.encoded_size_bytes(),
186            relation_desc: self.relation_desc.clone(),
187            shard_id: self.shard_id,
188            batches: vec![batch.into_transmittable_batch()],
189            inline_rows: Vec::new(),
190        };
191        Ok(PeekResponse::Stashed(Box::new(stashed_response)))
192    }
193
194    /// Finishes the batch as work of its own, and leaves the upload holding no parts.
195    ///
196    /// Flushing the buffered part and the uploads still in flight is the longest await an upload
197    /// makes, so a cancellation most likely lands there, with the builder already out of the
198    /// upload. A task the cancellation cannot reach holds it instead, and whoever ends up holding a
199    /// batch nobody will read deletes it.
200    async fn finish_batch(&mut self) -> Result<DeliveredBatch, StashError> {
201        let batch_builder = self.batch_builder.take().expect(BUILDER_TAKEN);
202        let upper = self.upper.clone();
203        let shard_id = self.shard_id;
204        let runtime = self.runtime.clone();
205
206        let (tx, rx) = oneshot::channel();
207        let _handle =
208            self.runtime
209                .spawn_named(|| format!("peek_stash::finish({shard_id})"), async move {
210                    let delivered = batch_builder
211                        .finish(upper)
212                        .await
213                        .map(|batch| DeliveredBatch::new(batch, runtime, shard_id))
214                        .map_err(StashError::FinishBatch);
215                    // A send that finds no receiver hands the delivery straight back, and dropping it
216                    // here deletes the batch. An error nobody is left to read is simply dropped.
217                    let _undelivered = tx.send(delivered);
218                });
219
220        // The task is detached rather than held, so it outlives this await and the only way the
221        // channel closes without a delivery is a runtime that is going away.
222        rx.await.map_err(|_| StashError::LostFinishTask)?
223    }
224
225    /// Schedules the deletion of whatever parts the upload still holds, and leaves it holding
226    /// none.
227    ///
228    /// Persist hands back a deletable handle only by finishing the batch, so the buffered rows are
229    /// written out first. That write reaches `persist_blob_target_size`, 128 MiB by default, and
230    /// nothing bounds how many abandoned uploads carry one at once, because the walk releases its
231    /// permit before this finishes.
232    ///
233    /// The handle does not reach parts a run merge already dropped from shard state. A reader
234    /// deleting a response it has finished with reaches the same set, so that bounds the builder
235    /// rather than abandonment.
236    ///
237    /// TODO: a builder teardown that surrendered the written parts without flushing the buffered
238    /// one would make this cost a delete and nothing else.
239    fn abandon(&mut self) {
240        let Some(batch_builder) = self.batch_builder.take() else {
241            return;
242        };
243
244        // An upload persist never took a part off holds its rows in memory alone, so its builder
245        // goes with it. Finishing here would upload the buffered part just to delete it again,
246        // and a part reaches blob storage only once the buffer passes
247        // `persist_blob_target_size`, which is far above the stash threshold that opened this
248        // upload. So this is the case nearly every abandoned upload is in.
249        if !self.wrote_parts {
250            drop(batch_builder);
251            return;
252        }
253
254        let upper = self.upper.clone();
255        let shard_id = self.shard_id;
256
257        // Scheduled, not awaited: the caller that matters most cannot await at all. Cancelling a
258        // peek aborts the walk driving it, and an aborted task is dropped rather than polled again,
259        // so the deletion reaches blob storage only as work outside that task. Entering the runtime
260        // explicitly lets this run from a `Drop`, which has no guaranteed runtime context.
261        //
262        // NOTE: this reaches only an upload a live replica gives up on. A replica that dies
263        // mid-upload, or one whose runtime is shutting down, leaves the parts behind for a
264        // reader-side sweep or persist's garbage collection.
265        let _handle =
266            self.runtime
267                .spawn_named(|| format!("peek_stash::discard({shard_id})"), async move {
268                    match batch_builder.finish(upper).await {
269                        Ok(batch) => batch.delete().await,
270                        Err(error) => {
271                            warn!(%shard_id, %error, "peek stash cannot delete an abandoned batch")
272                        }
273                    }
274                });
275    }
276}
277
278impl Drop for StashUpload {
279    /// Deletes the parts of an upload that ends without [`StashUpload::finish`], which is the
280    /// only cleanup a walk aborted mid-upload gets.
281    fn drop(&mut self) {
282        self.abandon();
283    }
284}
285
286/// A finished batch on its way to the response that will name it, deleted from blob storage if it
287/// never arrives.
288///
289/// A batch nobody takes out of a delivery is one no reader will be told how to find, so dropping it
290/// deletes it. That covers both ways a delivery goes unclaimed: a send that finds no receiver, and
291/// a receiver dropped between the send and the take. Persist's own `Drop for Batch` covers neither,
292/// logging the blob keys and leaving them.
293struct DeliveredBatch {
294    /// Taken by [`DeliveredBatch::take`], and a `None` says the batch has an owner that will
295    /// answer for it.
296    batch: Option<Batch<SourceData, (), Timestamp, i64>>,
297    runtime: Handle,
298    shard_id: ShardId,
299}
300
301impl DeliveredBatch {
302    fn new(
303        batch: Batch<SourceData, (), Timestamp, i64>,
304        runtime: Handle,
305        shard_id: ShardId,
306    ) -> Self {
307        Self {
308            batch: Some(batch),
309            runtime,
310            shard_id,
311        }
312    }
313
314    /// Claims the batch, consuming the delivery.
315    ///
316    /// The caller takes over the obligation: a batch dropped without being transmitted or deleted
317    /// leaves its blobs behind.
318    fn take(mut self) -> Batch<SourceData, (), Timestamp, i64> {
319        self.batch
320            .take()
321            .expect("a delivery holds its batch until it is claimed")
322    }
323}
324
325impl Drop for DeliveredBatch {
326    fn drop(&mut self) {
327        let Some(batch) = self.batch.take() else {
328            return;
329        };
330
331        let shard_id = self.shard_id;
332        let _handle = self
333            .runtime
334            .spawn_named(|| format!("peek_stash::delete({shard_id})"), async move {
335                batch.delete().await
336            });
337    }
338}
339
340/// Where a peek's rows go when they may not be answered with inline, and what opening the upload
341/// that writes them takes.
342///
343/// A driver holds a target, not an open upload: a walk that never crosses the stash threshold opens
344/// no shard and writes no byte, and most walks never do. A driver gets one exactly when its scan was
345/// opened stash-eligible, so a walk with no target has a scan that offers no batch.
346pub(super) struct StashTarget {
347    persist_clients: Arc<PersistClientCache>,
348    persist_location: PersistLocation,
349    /// The peek's uuid, which the shard the batch belongs to is derived from.
350    peek_uuid: Uuid,
351    /// The description the rows are written under, and the one the response reports.
352    relation_desc: RelationDesc,
353}
354
355impl StashTarget {
356    /// The stash `peek`'s rows go to, at `persist_location`.
357    pub(super) fn new(
358        peek: &Peek,
359        persist_clients: Arc<PersistClientCache>,
360        persist_location: PersistLocation,
361    ) -> Self {
362        Self {
363            persist_clients,
364            persist_location,
365            peek_uuid: peek.uuid,
366            relation_desc: peek.result_desc.clone(),
367        }
368    }
369
370    /// Opens the upload, whose batch builder holds at most `batch_max_runs` runs.
371    pub(super) async fn open(&self, batch_max_runs: usize) -> Result<StashUpload, StashError> {
372        StashUpload::open(
373            &self.persist_clients,
374            self.persist_location.clone(),
375            batch_max_runs,
376            self.peek_uuid,
377            self.relation_desc.clone(),
378        )
379        .await
380    }
381}
382
383/// Tests of the incremental stash upload, over the persist location a replica would write to.
384///
385/// [`tests::stashed_rows`] is shared with the drivers that feed an upload, which read a response
386/// back the same way.
387#[cfg(test)]
388pub(crate) mod tests;