Skip to main content

mz_storage_operators/
persist.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// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::fmt::Debug;
12use std::sync::{Arc, Weak};
13
14use mz_ore::task::AbortOnDropHandle;
15use mz_persist_client::batch::Batch;
16use mz_persist_client::{PersistClient, Schemas};
17use mz_persist_types::ShardId;
18use mz_persist_types::part::Part;
19use mz_repr::Timestamp;
20use mz_storage_types::StorageDiff;
21use mz_storage_types::sources::SourceData;
22use serde::{Deserialize, Serialize};
23use timely::progress::Antichain;
24use uuid::Uuid;
25
26/// A unique identifier for a [SharedBatchBuilder].
27#[derive(
28    Debug,
29    Copy,
30    Clone,
31    Serialize,
32    Deserialize,
33    Ord,
34    PartialOrd,
35    Eq,
36    PartialEq
37)]
38#[serde(transparent)]
39pub struct SharedBatchId(Uuid);
40
41impl SharedBatchId {
42    /// Returns a fresh, random id.
43    pub fn new() -> Self {
44        Self(Uuid::new_v4())
45    }
46}
47
48/// A struct from which to obtain a [SharedBatchBuilder].
49#[derive(Debug, Clone)]
50pub struct SharedBatches {
51    data: Arc<std::sync::Mutex<Inner>>,
52}
53
54#[derive(Debug, Default)]
55struct Inner {
56    /// The size of `states` after the last sweep of dead entries.
57    last_retained_len: usize,
58    states: BTreeMap<SharedBatchId, Weak<BatchState>>,
59}
60
61fn upgrade_or_init<T>(weak: &mut Weak<T>, init: impl FnOnce() -> T) -> Arc<T> {
62    if let Some(owned) = weak.upgrade() {
63        owned
64    } else {
65        let owned = Arc::new(init());
66        *weak = Arc::downgrade(&owned);
67        owned
68    }
69}
70
71impl SharedBatches {
72    /// Returns an empty registry. Clones share the registry.
73    pub fn new() -> Self {
74        Self {
75            data: Arc::default(),
76        }
77    }
78
79    /// Get a builder for the specified batch. If there is an existing, live builder for the same
80    /// batch id managed by [Self], any parts added to this builder will be included in the same,
81    /// shared batch; if not, a new batch will be created.
82    ///
83    /// This is designed so that, in a multi-worker dataflow, workers that are building the same batch
84    /// at the same time can write fewer, larger parts instead of many small ones. It does not guarantee
85    /// that there will be _precisely_ one batch across all workers, however.
86    ///
87    /// The batch's description is fixed by whichever caller creates it, so every caller sharing a
88    /// `shared_batch_id` must pass the same `shard_id`, `lower`, and `upper`, or this panics.
89    pub fn builder(
90        &self,
91        shared_batch_id: SharedBatchId,
92        client: PersistClient,
93        shard_id: ShardId,
94        shard_name: String,
95        schemas: Schemas<SourceData, ()>,
96        lower: Antichain<Timestamp>,
97        upper: Antichain<Timestamp>,
98    ) -> SharedBatchBuilder {
99        let mut guard = self.data.lock().unwrap();
100        let inner = &mut *guard;
101        // We clean out entries from the map where all shared batch handles have been dropped.
102        // Amortize by only scanning the map once it's doubled in size.
103        if inner.states.len() > inner.last_retained_len * 2 {
104            // Probe the count rather than `upgrade()`: the temporary strong reference an upgrade
105            // creates can make a concurrent `SharedBatchBuilder::finish` see a strong count above
106            // one and hand the batch to nobody, which seals the interval with no data. A count of
107            // zero cannot become live again, and a stale non-zero count only keeps a dead entry
108            // until the next sweep.
109            inner.states.retain(|_, weak| weak.strong_count() > 0);
110            inner.last_retained_len = inner.states.len();
111        }
112        let weak = inner.states.entry(shared_batch_id).or_default();
113        let desc = BatchDesc {
114            shard_id,
115            lower: lower.clone(),
116            upper: upper.clone(),
117        };
118        let state = upgrade_or_init(weak, || {
119            let (tx, mut rx) = tokio::sync::mpsc::channel(PARTS_IN_FLIGHT);
120            let task_id = format!("shared-batch-{shared_batch_id:?}-{shard_id}");
121            BatchState {
122                desc: desc.clone(),
123                tx,
124                handle: mz_ore::task::spawn(|| task_id, async move {
125                    let mut builder = None;
126                    while let Some(cmd) = rx.recv().await {
127                        match cmd {
128                            BatchCommand::Push(part) => {
129                                let builder = builder.get_or_insert_with(|| {
130                                    client.batch_builder(
131                                        shard_id,
132                                        &shard_name,
133                                        schemas.clone(),
134                                        lower.clone(),
135                                        None,
136                                    )
137                                });
138                                builder.add_part(part).await.expect("valid timestamps");
139                            }
140                        }
141                    }
142                    if let Some(builder) = builder {
143                        Some(builder.finish(upper).await.expect("valid upper bound"))
144                    } else {
145                        None
146                    }
147                })
148                .abort_on_drop(),
149            }
150        });
151
152        // A caller with a different description would otherwise write its updates under another
153        // caller's bounds.
154        assert_eq!(
155            state.desc, desc,
156            "shared batch {shared_batch_id:?} requested with conflicting descriptions",
157        );
158
159        SharedBatchBuilder {
160            batch_id: shared_batch_id,
161            shard_id,
162            state,
163        }
164    }
165}
166
167/// How many pushed parts a shared batch buffers before [SharedBatchBuilder::push] waits for the
168/// builder task to take them.
169const PARTS_IN_FLIGHT: usize = 4;
170
171/// The bounds a shared batch is built under, fixed by the caller that creates it.
172#[derive(Debug, Clone, PartialEq, Eq)]
173struct BatchDesc {
174    shard_id: ShardId,
175    lower: Antichain<Timestamp>,
176    upper: Antichain<Timestamp>,
177}
178
179enum BatchCommand {
180    Push(Part),
181}
182
183#[derive(Debug)]
184struct BatchState {
185    desc: BatchDesc,
186    tx: tokio::sync::mpsc::Sender<BatchCommand>,
187    /// Aborts the builder task when the last handle goes away without finishing, so a torn-down
188    /// sink stops writing parts.
189    handle: AbortOnDropHandle<Option<Batch<SourceData, (), Timestamp, StorageDiff>>>,
190}
191
192/// A handle for a shared batch builder. Everyone with a handle for the same builder can
193/// [Self::push] to that builder, but the last handle to call [Self::finish] will receive a batch
194/// containing all the data.
195///
196/// Dropping the last handle instead aborts the builder task. Parts it already uploaded stay in blob
197/// until persist's leaked-blob cleanup collects them.
198pub struct SharedBatchBuilder {
199    batch_id: SharedBatchId,
200    shard_id: ShardId,
201    state: Arc<BatchState>,
202}
203
204impl Debug for SharedBatchBuilder {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        f.debug_struct("SharedBatchBuilder")
207            .field("batch_id", &self.batch_id)
208            .field("shard_id", &self.shard_id)
209            .finish_non_exhaustive()
210    }
211}
212
213impl SharedBatchBuilder {
214    /// Include the provided part in the batch. If there are a large number of updates that have
215    /// not been flushed to S3, this call may wait.
216    pub async fn push(&self, part: Part) {
217        if part.len() == 0 {
218            return;
219        }
220        self.state
221            .tx
222            .send(BatchCommand::Push(part))
223            .await
224            .expect("shared batch builder task exited");
225    }
226
227    /// Fetch the results of the batch builder from the shared state, if any.
228    ///
229    /// Only the last builder to call this method will obtain the resulting batch, and that batch
230    /// will include all data written by all workers. Every handle must call this, also when it
231    /// pushed nothing, since any handle may be the last one.
232    pub async fn finish(self) -> Option<Batch<SourceData, (), Timestamp, StorageDiff>> {
233        let state = Arc::into_inner(self.state)?;
234        drop(state.tx); // The task only finishes the batch once the channel is closed.
235        state.handle.await
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use mz_persist_types::codec_impls::UnitSchema;
243    use mz_persist_types::part::PartBuilder;
244    use mz_repr::{Datum, RelationDesc, Row, SqlScalarType};
245
246    fn bool_schemas() -> Schemas<SourceData, ()> {
247        Schemas {
248            id: None,
249            key: Arc::new(
250                RelationDesc::builder()
251                    .with_column("test", SqlScalarType::Bool.nullable(true))
252                    .finish(),
253            ),
254            val: Arc::new(UnitSchema),
255        }
256    }
257
258    fn bool_part(schemas: &Schemas<SourceData, ()>) -> Part {
259        let mut builder = PartBuilder::new(&*schemas.key, &*schemas.val);
260        builder.push(
261            &SourceData(Ok(Row::pack_slice(&[Datum::True]))),
262            &(),
263            Timestamp::new(0),
264            1i64,
265        );
266        builder.finish()
267    }
268
269    #[mz_ore::test(tokio::test)]
270    async fn test_shared_batch() {
271        let client = PersistClient::new_for_tests().await;
272        let shared = SharedBatches::new();
273        let batch_id = SharedBatchId::new();
274        let shard_id = ShardId::new();
275        let schemas = bool_schemas();
276        let lower = Antichain::from_elem(0.into());
277        let upper = Antichain::from_elem(1.into());
278        let first = shared.builder(
279            batch_id,
280            client.clone(),
281            shard_id,
282            "test".to_string(),
283            schemas.clone(),
284            lower.clone(),
285            upper.clone(),
286        );
287        let second = shared.builder(
288            batch_id,
289            client,
290            shard_id,
291            "test".to_string(),
292            schemas.clone(),
293            lower.clone(),
294            upper.clone(),
295        );
296        let part = bool_part(&schemas);
297        first.push(part.clone()).await;
298        second.push(part.clone()).await;
299        assert!(
300            second.finish().await.is_none(),
301            "a handle that is not the last to finish receives no batch"
302        );
303        let batch = first.finish().await.unwrap();
304        assert_eq!(
305            batch.shard_id(),
306            shard_id,
307            "batch should be for the expected shard"
308        );
309        assert_eq!(
310            batch.into_hollow_batch().len,
311            2,
312            "batch should include updates from both pushes"
313        )
314    }
315
316    fn test_builder(
317        shared: &SharedBatches,
318        client: &PersistClient,
319        batch_id: SharedBatchId,
320        shard_id: ShardId,
321    ) -> SharedBatchBuilder {
322        shared.builder(
323            batch_id,
324            client.clone(),
325            shard_id,
326            "test".to_string(),
327            bool_schemas(),
328            Antichain::from_elem(0.into()),
329            Antichain::from_elem(1.into()),
330        )
331    }
332
333    #[mz_ore::test(tokio::test)]
334    #[should_panic(expected = "requested with conflicting descriptions")]
335    async fn test_shared_batch_conflicting_descriptions() {
336        let client = PersistClient::new_for_tests().await;
337        let shared = SharedBatches::new();
338        let batch_id = SharedBatchId::new();
339        let shard_id = ShardId::new();
340        let _first = test_builder(&shared, &client, batch_id, shard_id);
341        let _second = shared.builder(
342            batch_id,
343            client,
344            shard_id,
345            "test".to_string(),
346            bool_schemas(),
347            Antichain::from_elem(0.into()),
348            Antichain::from_elem(2.into()),
349        );
350    }
351
352    // A worker that goes away after pushing, without finishing, leaves its data to whichever
353    // handle finishes last.
354    #[mz_ore::test(tokio::test)]
355    async fn test_shared_batch_dropped_handle() {
356        let client = PersistClient::new_for_tests().await;
357        let shared = SharedBatches::new();
358        let batch_id = SharedBatchId::new();
359        let shard_id = ShardId::new();
360        let schemas = bool_schemas();
361        let first = test_builder(&shared, &client, batch_id, shard_id);
362        let second = test_builder(&shared, &client, batch_id, shard_id);
363        first.push(bool_part(&schemas)).await;
364        second.push(bool_part(&schemas)).await;
365        drop(second);
366        let batch = first
367            .finish()
368            .await
369            .expect("last finisher receives the batch");
370        assert_eq!(
371            batch.into_hollow_batch().len,
372            2,
373            "batch should include the dropped handle's updates"
374        );
375    }
376
377    // When every handle goes away without finishing, the batch is abandoned: it is delivered to
378    // nobody, and a later builder for the same id starts a fresh batch without its data.
379    #[mz_ore::test(tokio::test)]
380    async fn test_shared_batch_abandoned() {
381        let client = PersistClient::new_for_tests().await;
382        let shared = SharedBatches::new();
383        let batch_id = SharedBatchId::new();
384        let shard_id = ShardId::new();
385        let schemas = bool_schemas();
386        let first = test_builder(&shared, &client, batch_id, shard_id);
387        let second = test_builder(&shared, &client, batch_id, shard_id);
388        first.push(bool_part(&schemas)).await;
389        second.push(bool_part(&schemas)).await;
390        drop(first);
391        drop(second);
392        let third = test_builder(&shared, &client, batch_id, shard_id);
393        third.push(bool_part(&schemas)).await;
394        let batch = third
395            .finish()
396            .await
397            .expect("sole handle receives the batch");
398        assert_eq!(
399            batch.into_hollow_batch().len,
400            1,
401            "batch should exclude the abandoned batch's updates"
402        );
403    }
404
405    // Sweeping dead entries while handles for a live batch finish concurrently must not lose the
406    // batch: exactly one of the finishing handles receives it.
407    #[mz_ore::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
408    #[cfg_attr(miri, ignore)] // too slow
409    async fn test_shared_batch_sweep_races_finish() {
410        let client = PersistClient::new_for_tests().await;
411        let shared = SharedBatches::new();
412        let shard_id = ShardId::new();
413        let schemas = bool_schemas();
414        let lower = Antichain::from_elem(0.into());
415        let upper = Antichain::from_elem(1.into());
416        let builder = {
417            let schemas = schemas.clone();
418            move |batch_id| {
419                shared.builder(
420                    batch_id,
421                    client.clone(),
422                    shard_id,
423                    "test".to_string(),
424                    schemas.clone(),
425                    lower.clone(),
426                    upper.clone(),
427                )
428            }
429        };
430
431        // Each call registers an entry whose handle drops at once, so the map keeps doubling and
432        // nearly every call sweeps.
433        let churn: Vec<_> = (0..3)
434            .map(|_| {
435                let builder = builder.clone();
436                mz_ore::task::spawn(|| "churn", async move {
437                    loop {
438                        drop(builder(SharedBatchId::new()));
439                        tokio::task::yield_now().await;
440                    }
441                })
442                .abort_on_drop()
443            })
444            .collect();
445
446        for i in 0..5000 {
447            let batch_id = SharedBatchId::new();
448            let first = builder(batch_id);
449            let second = builder(batch_id);
450            first.push(bool_part(&schemas)).await;
451            // When both handles push, a sweep that evicted the live entry between the two
452            // `builder` calls would split the updates across two batches.
453            let pushes = if i % 2 == 0 {
454                second.push(bool_part(&schemas)).await;
455                2
456            } else {
457                1
458            };
459            let first = mz_ore::task::spawn(|| "finish-first", first.finish());
460            let second = mz_ore::task::spawn(|| "finish-second", second.finish());
461            let batches: Vec<_> = [first.await, second.await].into_iter().flatten().collect();
462            assert_eq!(
463                batches.len(),
464                1,
465                "exactly one finishing handle receives the batch"
466            );
467            for batch in batches {
468                assert_eq!(
469                    batch.into_hollow_batch().len,
470                    pushes,
471                    "the batch holds every handle's updates"
472                );
473            }
474        }
475        drop(churn);
476    }
477}