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.batch_builder::<SourceData, (), Timestamp, i64>(
127            shard_id,
128            "peek_stash",
129            write_schemas,
130            lower,
131            Some(batch_max_runs),
132        );
133
134        Ok(Self {
135            relation_desc,
136            shard_id,
137            batch_builder: Some(batch_builder),
138            upper,
139            num_rows: 0,
140            wrote_parts: false,
141            runtime: Handle::current(),
142        })
143    }
144
145    /// Writes `rows` to the stash.
146    ///
147    /// Every row given is written. Stopping where the peek's finishing has all it can use is the
148    /// scan's to decide, since it is the scan that holds the finishing and produces the rows.
149    ///
150    /// Fails where persist rejects the write, which leaves the upload unusable and the rows it
151    /// holds unanswerable.
152    pub(super) async fn push(&mut self, rows: RowBatch) -> Result<(), StashError> {
153        let batch_builder = self.batch_builder.as_mut().expect(BUILDER_TAKEN);
154
155        for (row, diff) in rows {
156            self.num_rows += u64::from(NonZeroU64::try_from(diff).expect("diff fits into u64"));
157            let diff: i64 = diff.into();
158
159            let added = batch_builder
160                .add(&SourceData(Ok(row)), &(), &Timestamp::default(), &diff)
161                .await
162                .map_err(StashError::WriteRow)?;
163            self.wrote_parts |= matches!(added, Added::RecordAndParts);
164        }
165
166        Ok(())
167    }
168
169    /// Finishes the batch and builds the response that names it.
170    ///
171    /// Every row of the answer is in the batch. The response's `inline_rows` are left for the
172    /// controller, which merges in the rows of workers whose share never reached the stash.
173    ///
174    /// Fails where persist rejects the batch, which takes the parts with it: persist keeps the
175    /// builder and hands back no handle to what it holds. Only a batch whose bounds do not admit
176    /// its own updates is refused, which this upload's fixed lower, upper and timestamp cannot
177    /// produce.
178    pub(super) async fn finish(mut self) -> Result<PeekResponse, StashError> {
179        let delivered = self.finish_batch().await?;
180        let batch = delivered.take();
181
182        let stashed_response = StashedPeekResponse {
183            num_rows_batches: self.num_rows,
184            encoded_size_bytes: batch.encoded_size_bytes(),
185            relation_desc: self.relation_desc.clone(),
186            shard_id: self.shard_id,
187            batches: vec![batch.into_transmittable_batch()],
188            inline_rows: Vec::new(),
189        };
190        Ok(PeekResponse::Stashed(Box::new(stashed_response)))
191    }
192
193    /// Finishes the batch as work of its own, and leaves the upload holding no parts.
194    ///
195    /// Flushing the buffered part and the uploads still in flight is the longest await an upload
196    /// makes, so a cancellation most likely lands there, with the builder already out of the
197    /// upload. A task the cancellation cannot reach holds it instead, and whoever ends up holding a
198    /// batch nobody will read deletes it.
199    async fn finish_batch(&mut self) -> Result<DeliveredBatch, StashError> {
200        let batch_builder = self.batch_builder.take().expect(BUILDER_TAKEN);
201        let upper = self.upper.clone();
202        let shard_id = self.shard_id;
203        let runtime = self.runtime.clone();
204
205        let (tx, rx) = oneshot::channel();
206        let _handle =
207            self.runtime
208                .spawn_named(|| format!("peek_stash::finish({shard_id})"), async move {
209                    let delivered = batch_builder
210                        .finish(upper)
211                        .await
212                        .map(|batch| DeliveredBatch::new(batch, runtime, shard_id))
213                        .map_err(StashError::FinishBatch);
214                    // A send that finds no receiver hands the delivery straight back, and dropping it
215                    // here deletes the batch. An error nobody is left to read is simply dropped.
216                    let _undelivered = tx.send(delivered);
217                });
218
219        // The task is detached rather than held, so it outlives this await and the only way the
220        // channel closes without a delivery is a runtime that is going away.
221        rx.await.map_err(|_| StashError::LostFinishTask)?
222    }
223
224    /// Schedules the deletion of whatever parts the upload still holds, and leaves it holding
225    /// none.
226    ///
227    /// Persist hands back a deletable handle only by finishing the batch, so the buffered rows are
228    /// written out first. That write reaches `persist_blob_target_size`, 128 MiB by default, and
229    /// nothing bounds how many abandoned uploads carry one at once, because the walk releases its
230    /// permit before this finishes.
231    ///
232    /// The handle does not reach parts a run merge already dropped from shard state. A reader
233    /// deleting a response it has finished with reaches the same set, so that bounds the builder
234    /// rather than abandonment.
235    ///
236    /// TODO: a builder teardown that surrendered the written parts without flushing the buffered
237    /// one would make this cost a delete and nothing else.
238    fn abandon(&mut self) {
239        let Some(batch_builder) = self.batch_builder.take() else {
240            return;
241        };
242
243        // An upload persist never took a part off holds its rows in memory alone, so its builder
244        // goes with it. Finishing here would upload the buffered part just to delete it again,
245        // and a part reaches blob storage only once the buffer passes
246        // `persist_blob_target_size`, which is far above the stash threshold that opened this
247        // upload. So this is the case nearly every abandoned upload is in.
248        if !self.wrote_parts {
249            drop(batch_builder);
250            return;
251        }
252
253        let upper = self.upper.clone();
254        let shard_id = self.shard_id;
255
256        // Scheduled, not awaited: the caller that matters most cannot await at all. Cancelling a
257        // peek aborts the walk driving it, and an aborted task is dropped rather than polled again,
258        // so the deletion reaches blob storage only as work outside that task. Entering the runtime
259        // explicitly lets this run from a `Drop`, which has no guaranteed runtime context.
260        //
261        // NOTE: this reaches only an upload a live replica gives up on. A replica that dies
262        // mid-upload, or one whose runtime is shutting down, leaves the parts behind for a
263        // reader-side sweep or persist's garbage collection.
264        let _handle =
265            self.runtime
266                .spawn_named(|| format!("peek_stash::discard({shard_id})"), async move {
267                    match batch_builder.finish(upper).await {
268                        Ok(batch) => batch.delete().await,
269                        Err(error) => {
270                            warn!(%shard_id, %error, "peek stash cannot delete an abandoned batch")
271                        }
272                    }
273                });
274    }
275}
276
277impl Drop for StashUpload {
278    /// Deletes the parts of an upload that ends without [`StashUpload::finish`], which is the
279    /// only cleanup a walk aborted mid-upload gets.
280    fn drop(&mut self) {
281        self.abandon();
282    }
283}
284
285/// A finished batch on its way to the response that will name it, deleted from blob storage if it
286/// never arrives.
287///
288/// A batch nobody takes out of a delivery is one no reader will be told how to find, so dropping it
289/// deletes it. That covers both ways a delivery goes unclaimed: a send that finds no receiver, and
290/// a receiver dropped between the send and the take. Persist's own `Drop for Batch` covers neither,
291/// logging the blob keys and leaving them.
292struct DeliveredBatch {
293    /// Taken by [`DeliveredBatch::take`], and a `None` says the batch has an owner that will
294    /// answer for it.
295    batch: Option<Batch<SourceData, (), Timestamp, i64>>,
296    runtime: Handle,
297    shard_id: ShardId,
298}
299
300impl DeliveredBatch {
301    fn new(
302        batch: Batch<SourceData, (), Timestamp, i64>,
303        runtime: Handle,
304        shard_id: ShardId,
305    ) -> Self {
306        Self {
307            batch: Some(batch),
308            runtime,
309            shard_id,
310        }
311    }
312
313    /// Claims the batch, consuming the delivery.
314    ///
315    /// The caller takes over the obligation: a batch dropped without being transmitted or deleted
316    /// leaves its blobs behind.
317    fn take(mut self) -> Batch<SourceData, (), Timestamp, i64> {
318        self.batch
319            .take()
320            .expect("a delivery holds its batch until it is claimed")
321    }
322}
323
324impl Drop for DeliveredBatch {
325    fn drop(&mut self) {
326        let Some(batch) = self.batch.take() else {
327            return;
328        };
329
330        let shard_id = self.shard_id;
331        let _handle = self
332            .runtime
333            .spawn_named(|| format!("peek_stash::delete({shard_id})"), async move {
334                batch.delete().await
335            });
336    }
337}
338
339/// Where a peek's rows go when they may not be answered with inline, and what opening the upload
340/// that writes them takes.
341///
342/// A driver holds a target, not an open upload: a walk that never crosses the stash threshold opens
343/// no shard and writes no byte, and most walks never do. A driver gets one exactly when its scan was
344/// opened stash-eligible, so a walk with no target has a scan that offers no batch.
345pub(super) struct StashTarget {
346    persist_clients: Arc<PersistClientCache>,
347    persist_location: PersistLocation,
348    /// The peek's uuid, which the shard the batch belongs to is derived from.
349    peek_uuid: Uuid,
350    /// The description the rows are written under, and the one the response reports.
351    relation_desc: RelationDesc,
352}
353
354impl StashTarget {
355    /// The stash `peek`'s rows go to, at `persist_location`.
356    pub(super) fn new(
357        peek: &Peek,
358        persist_clients: Arc<PersistClientCache>,
359        persist_location: PersistLocation,
360    ) -> Self {
361        Self {
362            persist_clients,
363            persist_location,
364            peek_uuid: peek.uuid,
365            relation_desc: peek.result_desc.clone(),
366        }
367    }
368
369    /// Opens the upload, whose batch builder holds at most `batch_max_runs` runs.
370    pub(super) async fn open(&self, batch_max_runs: usize) -> Result<StashUpload, StashError> {
371        StashUpload::open(
372            &self.persist_clients,
373            self.persist_location.clone(),
374            batch_max_runs,
375            self.peek_uuid,
376            self.relation_desc.clone(),
377        )
378        .await
379    }
380}
381
382/// Tests of the incremental stash upload, over the persist location a replica would write to.
383///
384/// [`tests::stashed_rows`] is shared with the drivers that feed an upload, which read a response
385/// back the same way.
386#[cfg(test)]
387pub(crate) mod tests;