1use 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#[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 pub fn new() -> Self {
44 Self(Uuid::new_v4())
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct SharedBatches {
51 data: Arc<std::sync::Mutex<Inner>>,
52}
53
54#[derive(Debug, Default)]
55struct Inner {
56 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 pub fn new() -> Self {
74 Self {
75 data: Arc::default(),
76 }
77 }
78
79 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 if inner.states.len() > inner.last_retained_len * 2 {
104 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 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
167const PARTS_IN_FLIGHT: usize = 4;
170
171#[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 handle: AbortOnDropHandle<Option<Batch<SourceData, (), Timestamp, StorageDiff>>>,
190}
191
192pub 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 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 pub async fn finish(self) -> Option<Batch<SourceData, (), Timestamp, StorageDiff>> {
233 let state = Arc::into_inner(self.state)?;
234 drop(state.tx); 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 #[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 #[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 #[mz_ore::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
408 #[cfg_attr(miri, ignore)] 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 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 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}