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;