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;