Skip to main content

mz_persist_client/operators/
shard_source.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
10//! A source that reads from a persist shard.
11
12use std::cell::RefCell;
13use std::collections::BTreeMap;
14use std::collections::VecDeque;
15use std::collections::hash_map::DefaultHasher;
16use std::convert::Infallible;
17use std::fmt::{Debug, Formatter};
18use std::future::Future;
19use std::hash::{Hash, Hasher};
20use std::pin::pin;
21use std::rc::Rc;
22use std::sync::Arc;
23use std::time::Instant;
24
25use anyhow::anyhow;
26use arrow::array::ArrayRef;
27use differential_dataflow::Hashable;
28use differential_dataflow::difference::Monoid;
29use differential_dataflow::lattice::Lattice;
30use futures::stream::FuturesUnordered;
31use futures_util::StreamExt;
32use mz_ore::cast::CastFrom;
33use mz_ore::collections::CollectionExt;
34use mz_persist_types::stats::PartStats;
35use mz_persist_types::{Codec, Codec64};
36use mz_timely_util::builder_async::{
37    Event, OperatorBuilder as AsyncOperatorBuilder, PressOnDropButton,
38};
39use timely::PartialOrder;
40use timely::container::CapacityContainerBuilder;
41use timely::dataflow::channels::pact::{Exchange, Pipeline};
42use timely::dataflow::operators::{Capability, CapabilitySet, ConnectLoop, Enter, Feedback, Leave};
43use timely::dataflow::{Scope, StreamVec};
44use timely::order::TotalOrder;
45use timely::progress::frontier::AntichainRef;
46use timely::progress::{Antichain, Timestamp, timestamp::Refines};
47use tracing::{debug, trace};
48
49use crate::batch::BLOB_TARGET_SIZE;
50use crate::cfg::{
51    RetryParameters, SOURCE_FETCH_CONCURRENCY, SOURCE_HYDRATION_FRONTIER_COALESCE_BYTES,
52    USE_CRITICAL_SINCE_SOURCE,
53};
54use crate::fetch::{ExchangeableBatchPart, FetchedBlob, Lease};
55use crate::internal::state::BatchPart;
56use crate::stats::{STATS_AUDIT_PERCENT, STATS_FILTER_ENABLED};
57use crate::{Diagnostics, PersistClient, ShardId};
58
59/// The result of applying an MFP to a part, if we know it.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub enum FilterResult {
62    /// This dataflow may or may not filter out any row in this part.
63    #[default]
64    Keep,
65    /// This dataflow is guaranteed to filter out all records in this part.
66    Discard,
67    /// This dataflow will keep all the rows, but the values are irrelevant:
68    /// include the given single-row KV data instead.
69    ReplaceWith {
70        /// The single-element key column.
71        key: ArrayRef,
72        /// The single-element val column.
73        val: ArrayRef,
74    },
75}
76
77impl FilterResult {
78    /// The noop filtering function: return the default value for all parts.
79    pub fn keep_all<T>(_stats: &PartStats, _frontier: AntichainRef<T>) -> FilterResult {
80        Self::Keep
81    }
82}
83
84/// Many dataflows, including the Persist source, encounter errors that are neither data-plane
85/// errors (a la SourceData) nor bugs. This includes:
86/// - lease timeouts: the source has failed to heartbeat, the lease timed out, and our inputs are
87///   GCed away. (But we'd be able to use the compaction output if we restart.)
88/// - external transactions: our Kafka transaction has failed, and we can't re-create it without
89///   re-ingesting a bunch of data we no longer have in memory. (But we could do on restart.)
90///
91/// It would be an error to simply exit from our dataflow operator, since that allows timely
92/// frontiers to advance, which signals progress that we haven't made. So we report the error and
93/// attempt to trigger a restart: either directly (via a `halt!`) or indirectly with a callback.
94#[derive(Clone)]
95pub enum ErrorHandler {
96    /// Halt the process on error.
97    Halt(&'static str),
98    /// Signal an error to a higher-level supervisor.
99    Signal(Rc<dyn Fn(anyhow::Error) + 'static>),
100}
101
102impl Debug for ErrorHandler {
103    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
104        match self {
105            ErrorHandler::Halt(name) => f.debug_tuple("ErrorHandler::Halt").field(name).finish(),
106            ErrorHandler::Signal(_) => f.write_str("ErrorHandler::Signal"),
107        }
108    }
109}
110
111impl ErrorHandler {
112    /// Returns a new error handler that uses the provided function to signal an error.
113    pub fn signal(signal_fn: impl Fn(anyhow::Error) + 'static) -> Self {
114        Self::Signal(Rc::new(signal_fn))
115    }
116
117    /// Signal an error to an error handler. This function never returns: logically it blocks until
118    /// restart, though that restart might be sooner (if halting) or later (if triggering a dataflow
119    /// restart, for example).
120    pub async fn report_and_stop(&self, error: anyhow::Error) -> ! {
121        match self {
122            ErrorHandler::Halt(name) => {
123                mz_ore::halt!("unhandled error in {name}: {error:#}")
124            }
125            ErrorHandler::Signal(callback) => {
126                let () = callback(error);
127                std::future::pending().await
128            }
129        }
130    }
131}
132
133/// Creates a new source that reads from a persist shard, distributing the work
134/// of reading data to all timely workers.
135///
136/// All times emitted will have been [advanced by] the given `as_of` frontier.
137/// All updates at times greater or equal to `until` will be suppressed.
138/// The `map_filter_project` argument, if supplied, may be partially applied,
139/// and any un-applied part of the argument will be left behind in the argument.
140///
141/// The `desc_transformer` interposes an operator in the stream before the
142/// chosen data is fetched. This is currently used to provide flow control... see
143/// usages for details.
144///
145/// [advanced by]: differential_dataflow::lattice::Lattice::advance_by
146pub fn shard_source<'inner, 'outer, K, V, T, D, DT, TOuter, C>(
147    outer: Scope<'outer, TOuter>,
148    scope: Scope<'inner, T>,
149    name: &str,
150    client: impl Fn() -> C,
151    shard_id: ShardId,
152    as_of: Option<Antichain<TOuter>>,
153    snapshot_mode: SnapshotMode,
154    until: Antichain<TOuter>,
155    desc_transformer: Option<DT>,
156    key_schema: Arc<K::Schema>,
157    val_schema: Arc<V::Schema>,
158    filter_fn: impl FnMut(&PartStats, AntichainRef<TOuter>) -> FilterResult + 'static,
159    // If Some, an override for the default listen sleep retry parameters.
160    listen_sleep: Option<impl Fn() -> RetryParameters + 'static>,
161    start_signal: impl Future<Output = ()> + 'static,
162    error_handler: ErrorHandler,
163) -> (
164    StreamVec<'inner, T, FetchedBlob<K, V, TOuter, D>>,
165    Vec<PressOnDropButton>,
166)
167where
168    K: Debug + Codec,
169    V: Debug + Codec,
170    D: Monoid + Codec64 + Send + Sync,
171    // TODO: Figure out how to get rid of the TotalOrder bound :(.
172    TOuter: Timestamp + Lattice + Codec64 + TotalOrder + Sync,
173    T: Refines<TOuter>,
174    DT: FnOnce(
175        Scope<'inner, T>,
176        StreamVec<'inner, T, (usize, ExchangeableBatchPart<TOuter>)>,
177        usize,
178    ) -> (
179        StreamVec<'inner, T, (usize, ExchangeableBatchPart<TOuter>)>,
180        Vec<PressOnDropButton>,
181    ),
182    C: Future<Output = PersistClient> + Send + 'static,
183{
184    // WARNING! If emulating any of this code, you should read the doc string on
185    // [`LeasedBatchPart`] and [`Subscribe`] or will likely run into intentional
186    // panics.
187    //
188    // This source is split as such:
189    // 1. Sets up `async_stream`, which only yields data (parts) on one chosen
190    //    worker. Generating also generates SeqNo leases on the chosen worker,
191    //    ensuring `part`s do not get GCed while in flight.
192    // 2. Part distribution: A timely source operator which continuously reads
193    //    from that stream, and distributes the data among workers.
194    // 3. Part fetcher: A timely operator which downloads the part's contents
195    //    from S3, and outputs them to a timely stream. Additionally, the
196    //    operator returns the `LeasedBatchPart` to the original worker, so it
197    //    can release the SeqNo lease.
198
199    let chosen_worker = usize::cast_from(name.hashed()) % scope.peers();
200
201    let mut tokens = vec![];
202
203    // we can safely pass along a zero summary from this feedback edge,
204    // as the input is disconnected from the operator's output
205    let (completed_fetches_feedback_handle, completed_fetches_feedback_stream) =
206        scope.feedback(T::Summary::default());
207
208    // Sniff out if this is on behalf of a transient dataflow. This doesn't
209    // affect the fetch behavior, it just causes us to use a different set of
210    // metrics.
211    let is_transient = !until.is_empty();
212
213    let (descs, descs_token) = shard_source_descs::<K, V, D, TOuter>(
214        outer,
215        name,
216        client(),
217        shard_id.clone(),
218        as_of,
219        snapshot_mode,
220        until,
221        completed_fetches_feedback_stream.leave(outer),
222        chosen_worker,
223        Arc::clone(&key_schema),
224        Arc::clone(&val_schema),
225        filter_fn,
226        listen_sleep,
227        start_signal,
228        error_handler.clone(),
229    );
230    tokens.push(descs_token);
231
232    let descs = descs.enter(scope);
233    let descs = match desc_transformer {
234        Some(desc_transformer) => {
235            let (descs, extra_tokens) = desc_transformer(scope, descs, chosen_worker);
236            tokens.extend(extra_tokens);
237            descs
238        }
239        None => descs,
240    };
241
242    let (parts, completed_fetches_stream, fetch_token) = shard_source_fetch::<K, V, TOuter, D, T>(
243        descs,
244        name,
245        client(),
246        shard_id,
247        key_schema,
248        val_schema,
249        is_transient,
250        error_handler,
251    );
252    completed_fetches_stream.connect_loop(completed_fetches_feedback_handle);
253    tokens.push(fetch_token);
254
255    (parts, tokens)
256}
257
258/// An enum describing whether a snapshot should be emitted
259#[derive(Debug, Clone, Copy)]
260pub enum SnapshotMode {
261    /// The snapshot will be included in the stream
262    Include,
263    /// The snapshot will not be included in the stream
264    Exclude,
265}
266
267#[derive(Debug)]
268struct LeaseManager<T> {
269    leases: BTreeMap<T, Vec<Lease>>,
270}
271
272impl<T: Timestamp + Codec64> LeaseManager<T> {
273    fn new() -> Self {
274        Self {
275            leases: BTreeMap::new(),
276        }
277    }
278
279    /// Track a lease associated with a particular time.
280    fn push_at(&mut self, time: T, lease: Lease) {
281        self.leases.entry(time).or_default().push(lease);
282    }
283
284    /// Discard any leases for data that aren't past the given frontier.
285    fn advance_to(&mut self, frontier: AntichainRef<T>)
286    where
287        // If we allowed partial orders, we'd need to reconsider every key on each advance.
288        T: TotalOrder,
289    {
290        while let Some(first) = self.leases.first_entry() {
291            if frontier.less_equal(first.key()) {
292                break; // This timestamp is still live!
293            }
294            drop(first.remove());
295        }
296    }
297}
298
299pub(crate) fn shard_source_descs<'outer, K, V, D, TOuter>(
300    scope: Scope<'outer, TOuter>,
301    name: &str,
302    client: impl Future<Output = PersistClient> + Send + 'static,
303    shard_id: ShardId,
304    as_of: Option<Antichain<TOuter>>,
305    snapshot_mode: SnapshotMode,
306    until: Antichain<TOuter>,
307    completed_fetches_stream: StreamVec<'outer, TOuter, Infallible>,
308    chosen_worker: usize,
309    key_schema: Arc<K::Schema>,
310    val_schema: Arc<V::Schema>,
311    mut filter_fn: impl FnMut(&PartStats, AntichainRef<TOuter>) -> FilterResult + 'static,
312    // If Some, an override for the default listen sleep retry parameters.
313    listen_sleep: Option<impl Fn() -> RetryParameters + 'static>,
314    start_signal: impl Future<Output = ()> + 'static,
315    error_handler: ErrorHandler,
316) -> (
317    StreamVec<'outer, TOuter, (usize, ExchangeableBatchPart<TOuter>)>,
318    PressOnDropButton,
319)
320where
321    K: Debug + Codec,
322    V: Debug + Codec,
323    D: Monoid + Codec64 + Send + Sync,
324    // TODO: Figure out how to get rid of the TotalOrder bound :(.
325    TOuter: Timestamp + Lattice + Codec64 + TotalOrder + Sync,
326{
327    let worker_index = scope.index();
328    let num_workers = scope.peers();
329
330    // This is a generator that sets up an async `Stream` that can be continuously polled to get the
331    // values that are `yield`-ed from it's body.
332    let name_owned = name.to_owned();
333
334    // Create a shared slot between the operator to store the listen handle
335    let listen_handle = Rc::new(RefCell::new(None));
336    let return_listen_handle = Rc::clone(&listen_handle);
337
338    // Create a oneshot channel to give the part returner a SubscriptionLeaseReturner
339    let (tx, rx) = tokio::sync::oneshot::channel::<Rc<RefCell<LeaseManager<TOuter>>>>();
340    let mut builder = AsyncOperatorBuilder::new(
341        format!("shard_source_descs_return({})", name),
342        scope.clone(),
343    );
344    let mut completed_fetches = builder.new_disconnected_input(completed_fetches_stream, Pipeline);
345    // This operator doesn't need to use a token because it naturally exits when its input
346    // frontier reaches the empty antichain.
347    builder.build(move |_caps| async move {
348        let Ok(leases) = rx.await else {
349            // Either we're not the chosen worker or the dataflow was shutdown before the
350            // subscriber was even created.
351            return;
352        };
353        while let Some(event) = completed_fetches.next().await {
354            let Event::Progress(frontier) = event else {
355                continue;
356            };
357            leases.borrow_mut().advance_to(frontier.borrow());
358        }
359        // Make it explicit that the subscriber is kept alive until we have finished returning parts
360        drop(return_listen_handle);
361    });
362
363    let mut builder =
364        AsyncOperatorBuilder::new(format!("shard_source_descs({})", name), scope.clone());
365    let (descs_output, descs_stream) = builder.new_output::<CapacityContainerBuilder<_>>();
366
367    #[allow(clippy::await_holding_refcell_ref)]
368    let shutdown_button = builder.build(move |caps| async move {
369        let mut cap_set = CapabilitySet::from_elem(caps.into_element());
370
371        // Only one worker is responsible for distributing parts
372        if worker_index != chosen_worker {
373            trace!(
374                "We are not the chosen worker ({}), exiting...",
375                chosen_worker
376            );
377            return;
378        }
379
380        // Internally, the `open_leased_reader` call registers a new LeasedReaderId and then fires
381        // up a background tokio task to heartbeat it. It is possible that we might get a
382        // particularly adversarial scheduling where the CRDB query to register the id is sent and
383        // then our Future is not polled again for a long time, resulting is us never spawning the
384        // heartbeat task. Run reader creation in a task to attempt to defend against this.
385        //
386        // TODO: Really we likely need to swap the inners of all persist operators to be
387        // communicating with a tokio task over a channel, but that's much much harder, so for now
388        // we whack the moles as we see them.
389        let mut read = mz_ore::task::spawn(|| format!("shard_source_reader({})", name_owned), {
390            let diagnostics = Diagnostics {
391                handle_purpose: format!("shard_source({})", name_owned),
392                shard_name: name_owned.clone(),
393            };
394            async move {
395                let client = client.await;
396                client
397                    .open_leased_reader::<K, V, TOuter, D>(
398                        shard_id,
399                        key_schema,
400                        val_schema,
401                        diagnostics,
402                        USE_CRITICAL_SINCE_SOURCE.get(client.dyncfgs()),
403                    )
404                    .await
405            }
406        })
407        .await
408        .expect("could not open persist shard");
409
410        // Wait for the start signal only after we have obtained a read handle. This makes "cannot
411        // serve requested as_of" panics caused by (database-issues#8729) significantly less
412        // likely.
413        let () = start_signal.await;
414
415        let cfg = read.cfg.clone();
416        let metrics = Arc::clone(&read.metrics);
417
418        let as_of = as_of.unwrap_or_else(|| read.since().clone());
419
420        // Eagerly downgrade our frontier to the initial as_of. This makes sure
421        // that the output frontier of the `persist_source` closely tracks the
422        // `upper` frontier of the persist shard. It might be that the snapshot
423        // for `as_of` is not initially available yet, but this makes sure we
424        // already downgrade to it.
425        //
426        // Downstream consumers might rely on close frontier tracking for making
427        // progress. For example, the `persist_sink` needs to know the
428        // up-to-date upper of the output shard to make progress because it will
429        // only write out new data once it knows that earlier writes went
430        // through, including the initial downgrade of the shard upper to the
431        // `as_of`.
432        //
433        // NOTE: We have to do this before our `snapshot()` call because that
434        // will block when there is no data yet available in the shard.
435        cap_set.downgrade(as_of.clone());
436
437        let mut snapshot_parts =
438            match snapshot_mode {
439                SnapshotMode::Include => match read.snapshot(as_of.clone()).await {
440                    Ok(parts) => parts,
441                    Err(e) => error_handler
442                        .report_and_stop(anyhow!(
443                            "{name_owned}: {shard_id} cannot serve requested as_of {as_of:?}: {e:?}"
444                        ))
445                        .await,
446                },
447                SnapshotMode::Exclude => vec![],
448            };
449
450        // We're about to start producing parts to be fetched whose leases will be returned by the
451        // `shard_source_descs_return` operator above. In order for that operator to successfully
452        // return the leases we send it the lease returner associated with our shared subscriber.
453        let leases = Rc::new(RefCell::new(LeaseManager::new()));
454        tx.send(Rc::clone(&leases))
455            .expect("lease returner exited before desc producer");
456
457        // Recent shard upper observed at hydration time. While the source is still
458        // catching up to it we coalesce frontier downgrades (see the loop below); once
459        // `current_frontier` reaches it the source is live and we forward every batch's
460        // progress so steady-state frontier tracking stays tight. Read before `listen`
461        // consumes `read`.
462        let replay_upper = read.shared_upper();
463
464        // Store the listen handle in the shared slot so that it stays alive until both operators
465        // exit
466        let mut listen = listen_handle.borrow_mut();
467        let listen = match read.listen(as_of.clone()).await {
468            Ok(handle) => listen.insert(handle),
469            Err(e) => {
470                error_handler
471                    .report_and_stop(anyhow!(
472                        "{name_owned}: {shard_id} cannot serve requested as_of {as_of:?}: {e:?}"
473                    ))
474                    .await
475            }
476        };
477
478        let listen_retry = listen_sleep.as_ref().map(|retry| retry());
479
480        // The head of the stream is enriched with the snapshot parts if they exist
481        let listen_head = if !snapshot_parts.is_empty() {
482            let (mut parts, progress) = listen.next(listen_retry).await;
483            snapshot_parts.append(&mut parts);
484            futures::stream::iter(Some((snapshot_parts, progress)))
485        } else {
486            futures::stream::iter(None)
487        };
488
489        // The tail of the stream is all subsequent parts
490        let listen_tail = futures::stream::unfold(listen, |listen| async move {
491            Some((listen.next(listen_retry).await, listen))
492        });
493
494        let mut shard_stream = pin!(listen_head.chain(listen_tail));
495
496        // Ideally, we'd like our audit overhead to be proportional to the actual amount of "real"
497        // work we're doing in the source. So: start with a small, constant budget; add to the
498        // budget when we do real work; and skip auditing a part if we don't have the budget for it.
499        let mut audit_budget_bytes = u64::cast_from(BLOB_TARGET_SIZE.get(&cfg).saturating_mul(2));
500
501        // All future updates will be timestamped after this frontier.
502        let mut current_frontier = as_of.clone();
503
504        // While catching up to `replay_upper`, coalesce frontier downgrades until at
505        // least this many encoded bytes have been emitted at the held capability. This
506        // turns a long historical replay (one persist batch ~ one write ~ 1/s) from one
507        // progress round per batch into a handful of larger steps, which is what bounds
508        // the number of downstream arrangement-maintenance passes. `0` disables it. The
509        // budget caps how much the downstream batcher stages before it can seal, so we
510        // never trade the per-batch storm for an unbounded single batch.
511        let coalesce_target = u64::cast_from(SOURCE_HYDRATION_FRONTIER_COALESCE_BYTES.get(&cfg));
512        // Encoded bytes emitted since the last forwarded progress.
513        let mut coalesced_bytes: u64 = 0;
514
515        // If `until.less_equal(current_frontier)`, it means that all subsequent batches will contain only
516        // times greater or equal to `until`, which means they can be dropped in their entirety.
517        while !PartialOrder::less_equal(&until, &current_frontier) {
518            let (parts, progress) = shard_stream.next().await.expect("infinite stream");
519
520            let mut batch_bytes: u64 = 0;
521
522            // Emit the part at the `(ts, 0)` time. The `granular_backpressure`
523            // operator will refine this further, if its enabled.
524            let current_ts = current_frontier
525                .as_option()
526                .expect("until should always be <= the empty frontier");
527            let session_cap = cap_set.delayed(current_ts);
528
529            for mut part_desc in parts {
530                // TODO: Push more of this logic into LeasedBatchPart like we've
531                // done for project?
532                if STATS_FILTER_ENABLED.get(&cfg) {
533                    let filter_result = match &part_desc.part {
534                        BatchPart::Hollow(x) => {
535                            let should_fetch =
536                                x.stats.as_ref().map_or(FilterResult::Keep, |stats| {
537                                    // Stats written by a newer version may
538                                    // not decode. The sound fallback is to
539                                    // fetch the part.
540                                    match stats.try_decode() {
541                                        Ok(stats) => filter_fn(&stats, current_frontier.borrow()),
542                                        Err(err) => {
543                                            tracing::warn!(
544                                                %err,
545                                                "could not decode part stats, fetching part"
546                                            );
547                                            FilterResult::Keep
548                                        }
549                                    }
550                                });
551                            should_fetch
552                        }
553                        BatchPart::Inline { .. } => FilterResult::Keep,
554                    };
555                    // Apply the filter: discard or substitute the part if required.
556                    let bytes = u64::cast_from(part_desc.encoded_size_bytes());
557                    match filter_result {
558                        FilterResult::Keep => {
559                            audit_budget_bytes = audit_budget_bytes.saturating_add(bytes);
560                        }
561                        FilterResult::Discard => {
562                            metrics.pushdown.parts_filtered_count.inc();
563                            metrics.pushdown.parts_filtered_bytes.inc_by(bytes);
564                            let should_audit = match &part_desc.part {
565                                BatchPart::Hollow(x) => {
566                                    let mut h = DefaultHasher::new();
567                                    x.key.hash(&mut h);
568                                    usize::cast_from(h.finish()) % 100
569                                        < STATS_AUDIT_PERCENT.get(&cfg)
570                                }
571                                BatchPart::Inline { .. } => false,
572                            };
573                            if should_audit && bytes < audit_budget_bytes {
574                                audit_budget_bytes -= bytes;
575                                metrics.pushdown.parts_audited_count.inc();
576                                metrics.pushdown.parts_audited_bytes.inc_by(bytes);
577                                part_desc.request_filter_pushdown_audit();
578                            } else {
579                                debug!(
580                                    "skipping part because of stats filter {:?}",
581                                    part_desc.part.stats()
582                                );
583                                continue;
584                            }
585                        }
586                        FilterResult::ReplaceWith { key, val } => {
587                            part_desc.maybe_optimize(&cfg, key, val);
588                            audit_budget_bytes = audit_budget_bytes.saturating_add(bytes);
589                        }
590                    }
591                    let bytes = u64::cast_from(part_desc.encoded_size_bytes());
592                    if part_desc.part.is_inline() {
593                        metrics.pushdown.parts_inline_count.inc();
594                        metrics.pushdown.parts_inline_bytes.inc_by(bytes);
595                    } else {
596                        metrics.pushdown.parts_fetched_count.inc();
597                        metrics.pushdown.parts_fetched_bytes.inc_by(bytes);
598                    }
599                }
600
601                // Give the part to a random worker. This isn't round robin in an attempt to avoid
602                // skew issues: if your parts alternate size large, small, then you'll end up only
603                // using half of your workers.
604                //
605                // There's certainly some other things we could be doing instead here, but this has
606                // seemed to work okay so far. Continue to revisit as necessary.
607                let worker_idx = usize::cast_from(Instant::now().hashed()) % num_workers;
608                batch_bytes =
609                    batch_bytes.saturating_add(u64::cast_from(part_desc.encoded_size_bytes()));
610                let (part, lease) = part_desc.into_exchangeable_part();
611                leases.borrow_mut().push_at(current_ts.clone(), lease);
612                descs_output.give(&session_cap, (worker_idx, part));
613            }
614
615            current_frontier.join_assign(&progress);
616            coalesced_bytes = coalesced_bytes.saturating_add(batch_bytes);
617
618            // Coalesce the frontier downgrade while still catching up to `replay_upper`
619            // and below the byte budget. Parts carry their real timestamps regardless, so
620            // holding the frontier back only batches downstream progress rounds. Once live
621            // (caught up to `replay_upper`) or with coalescing disabled we forward every
622            // batch, keeping steady-state tracking tight for consumers like `persist_sink`.
623            let caught_up = PartialOrder::less_equal(&replay_upper, &current_frontier);
624            let coalesce = coalesce_target > 0 && !caught_up && coalesced_bytes < coalesce_target;
625            if !coalesce {
626                coalesced_bytes = 0;
627                cap_set.downgrade(current_frontier.iter());
628            }
629        }
630    });
631
632    (descs_stream, shutdown_button.press_on_drop())
633}
634
635pub(crate) fn shard_source_fetch<'inner, K, V, T, D, TInner>(
636    descs: StreamVec<'inner, TInner, (usize, ExchangeableBatchPart<T>)>,
637    name: &str,
638    client: impl Future<Output = PersistClient> + Send + 'static,
639    shard_id: ShardId,
640    key_schema: Arc<K::Schema>,
641    val_schema: Arc<V::Schema>,
642    is_transient: bool,
643    error_handler: ErrorHandler,
644) -> (
645    StreamVec<'inner, TInner, FetchedBlob<K, V, T, D>>,
646    StreamVec<'inner, TInner, Infallible>,
647    PressOnDropButton,
648)
649where
650    K: Debug + Codec,
651    V: Debug + Codec,
652    T: Timestamp + Lattice + Codec64 + Sync,
653    D: Monoid + Codec64 + Send + Sync,
654    TInner: Timestamp + Refines<T>,
655{
656    let mut builder =
657        AsyncOperatorBuilder::new(format!("shard_source_fetch({})", name), descs.scope());
658    let (fetched_output, fetched_stream) = builder.new_output::<CapacityContainerBuilder<_>>();
659    let (completed_fetches_output, completed_fetches_stream) =
660        builder.new_output::<CapacityContainerBuilder<Vec<Infallible>>>();
661    let mut descs_input = builder.new_input_for_many(
662        descs,
663        Exchange::new(|&(i, _): &(usize, _)| u64::cast_from(i)),
664        [&fetched_output, &completed_fetches_output],
665    );
666    let name_owned = name.to_owned();
667
668    let shutdown_button = builder.build(move |_capabilities| async move {
669        // Open the fetcher in a task to defend against an adversarial schedule
670        // delaying its background work, and read the concurrency dyncfg while we
671        // hold the client. See the equivalent reasoning in `shard_source_descs`.
672        let (fetcher, max_concurrency) =
673            mz_ore::task::spawn(|| format!("shard_source_fetch({})", name_owned), {
674                let diagnostics = Diagnostics {
675                    shard_name: name_owned.clone(),
676                    handle_purpose: format!("shard_source_fetch batch fetcher {}", name_owned),
677                };
678                async move {
679                    let client = client.await;
680                    // Up to this many part fetches run concurrently to amortize
681                    // the blob-store round-trip, which dominates when there are
682                    // many small parts. Results are keyed by time below, so
683                    // completions in any order are fine; total in-flight bytes
684                    // stay bounded by the fetch semaphore inside
685                    // `fetch_leased_part`.
686                    let max_concurrency = SOURCE_FETCH_CONCURRENCY.get(client.dyncfgs()).max(1);
687                    let fetcher = client
688                        .create_batch_fetcher::<K, V, T, D>(
689                            shard_id,
690                            key_schema,
691                            val_schema,
692                            is_transient,
693                            diagnostics,
694                        )
695                        .await
696                        .expect("shard codecs should not change");
697                    (fetcher, max_concurrency)
698                }
699            })
700            .await;
701
702        // Fetch one part on a per-call clone of the fetcher (cheap: shares the
703        // schema cache), carrying the part's input capabilities through so they
704        // come back with the result. The missing-blob diagnostics round-trip
705        // happens inside the future, so the error surfaces only after the fetch
706        // has truly failed.
707        let fetch_one = |caps: [Capability<TInner>; 2], part: ExchangeableBatchPart<T>| {
708            let mut fetcher = fetcher.clone();
709            async move {
710                let reader_id = part.reader_id().clone();
711                let fetched = fetcher
712                    .fetch_leased_part(part)
713                    .await
714                    .expect("shard_id should match across all workers");
715                let fetched = match fetched {
716                    Ok(fetched) => Ok(fetched),
717                    Err(blob_key) => {
718                        // Ideally, readers should never encounter a missing blob. They place a
719                        // seqno hold as they consume their snapshot/listen, preventing any blobs
720                        // they need from being deleted by garbage collection, and all blob
721                        // implementations are linearizable so there should be no possibility of
722                        // stale reads.
723                        //
724                        // However, it is possible for a lease to expire given a sustained period
725                        // of downtime, which could allow parts we expect to exist to be
726                        // deleted... at which point our best option is to request a restart.
727                        // Check the state of the minting reader's lease to tell the two cases
728                        // apart.
729                        let diagnostics = fetcher.missing_blob_diagnostics(&reader_id).await;
730                        Err(anyhow!(
731                            "batch fetcher could not fetch batch part {}: {}",
732                            blob_key,
733                            diagnostics
734                        ))
735                    }
736                };
737                (caps, fetched)
738            }
739        };
740
741        // Descs accepted from the input but not yet handed to a fetch, FIFO.
742        // Each carries its input capabilities (data + completed-fetches), so
743        // buffering here holds no progress hostage; it only bounds how many
744        // fetches run at once (and thus how many parts are resident in memory).
745        // Timely tracks the frontier through these capabilities: it advances
746        // past a time only once every fetch minted at that time has completed
747        // and dropped its clones, releasing the parts' leases on the chosen
748        // worker via the completed-fetches feedback. Carrying capabilities
749        // rather than a separate time-keyed map makes correctness independent of
750        // the order results come back in.
751        let mut pending: VecDeque<([Capability<TInner>; 2], ExchangeableBatchPart<T>)> =
752            VecDeque::new();
753        let mut in_flight = FuturesUnordered::new();
754        let mut input_done = false;
755
756        loop {
757            // Start fetches up to the concurrency cap.
758            while in_flight.len() < max_concurrency {
759                let Some((caps, part)) = pending.pop_front() else {
760                    break;
761                };
762                in_flight.push(fetch_one(caps, part));
763            }
764
765            tokio::select! {
766                // Emit completed fetches first, so `in_flight` drains and we do
767                // not hold more than `max_concurrency` parts in memory.
768                biased;
769                Some(([cap, _], fetched)) = in_flight.next(), if !in_flight.is_empty() => {
770                    match fetched {
771                        Ok(fetched) => fetched_output.give(&cap, fetched),
772                        Err(e) => {
773                            // `report_and_stop` never returns, freezing the
774                            // operator: `cap` (and every other in-flight and
775                            // pending capability) stays held, so the data frontier
776                            // never advances past the part we failed to emit.
777                            error_handler.report_and_stop(e).await;
778                        }
779                    }
780                }
781                // Accept new descs while the input is live. Their fetches are
782                // throttled by the `pending` queue above, not here.
783                event = descs_input.next(), if !input_done => {
784                    match event {
785                        Some(Event::Data(caps, data)) => {
786                            // `LeasedBatchPart`es cannot be dropped at this point
787                            // w/o panicking, so swap them to an owned version.
788                            for (_idx, part) in data {
789                                pending.push_back((caps.clone(), part));
790                            }
791                        }
792                        Some(Event::Progress(_)) => {}
793                        None => input_done = true,
794                    }
795                }
796                // Input is exhausted and no fetches remain: we're done.
797                else => break,
798            }
799        }
800    });
801
802    (
803        fetched_stream,
804        completed_fetches_stream,
805        shutdown_button.press_on_drop(),
806    )
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use std::sync::Arc;
813
814    use mz_persist::location::{Blob, SeqNo};
815    use mz_persist_types::codec_impls::StringSchema;
816    use timely::dataflow::operators::Leave;
817    use timely::dataflow::operators::Probe;
818    use timely::dataflow::operators::capture::{Capture, Event as CaptureEvent};
819    use timely::dataflow::operators::probe::Handle as ProbeHandle;
820    use timely::progress::Antichain;
821
822    use crate::batch::{INLINE_WRITES_SINGLE_MAX_BYTES, INLINE_WRITES_TOTAL_MAX_BYTES};
823    use crate::cache::PersistClientCache;
824    use crate::internal::paths::{BlobKey, PartialBlobKey};
825    use crate::operators::shard_source::shard_source;
826    use crate::{Diagnostics, PersistLocation, ShardId};
827
828    #[mz_ore::test]
829    fn test_lease_manager() {
830        let lease = Lease::new(SeqNo::minimum());
831        let mut manager = LeaseManager::new();
832        for t in 0u64..10 {
833            manager.push_at(t, lease.clone());
834        }
835        assert_eq!(lease.count(), 11);
836        manager.advance_to(AntichainRef::new(&[5]));
837        assert_eq!(lease.count(), 6);
838        manager.advance_to(AntichainRef::new(&[3]));
839        assert_eq!(lease.count(), 6);
840        manager.advance_to(AntichainRef::new(&[9]));
841        assert_eq!(lease.count(), 2);
842        manager.advance_to(AntichainRef::new(&[10]));
843        assert_eq!(lease.count(), 1);
844    }
845
846    /// Verifies that a `shard_source` will downgrade it's output frontier to
847    /// the `since` of the shard when no explicit `as_of` is given. Even if
848    /// there is no data/no snapshot available in the
849    /// shard.
850    ///
851    /// NOTE: This test is weird: if everything is good it will pass. If we
852    /// break the assumption that we test this will time out and we will notice.
853    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
854    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
855    async fn test_shard_source_implicit_initial_as_of() {
856        let persist_client = PersistClient::new_for_tests().await;
857
858        let expected_frontier = 42;
859        let shard_id = ShardId::new();
860
861        initialize_shard(
862            &persist_client,
863            shard_id,
864            Antichain::from_elem(expected_frontier),
865        )
866        .await;
867
868        let res = timely::execute::execute_directly(move |worker| {
869            let until = Antichain::new();
870
871            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
872                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
873                    let transformer = move |_, descs, _| (descs, vec![]);
874                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
875                        outer,
876                        scope,
877                        "test_source",
878                        move || std::future::ready(persist_client.clone()),
879                        shard_id,
880                        None, // No explicit as_of!
881                        SnapshotMode::Include,
882                        until,
883                        Some(transformer),
884                        Arc::new(
885                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
886                        ),
887                        Arc::new(
888                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
889                        ),
890                        FilterResult::keep_all,
891                        false.then_some(|| unreachable!()),
892                        async {},
893                        ErrorHandler::Halt("test"),
894                    );
895                    (stream.leave(outer), tokens)
896                });
897
898                let probe = ProbeHandle::new();
899                let _stream = stream.probe_with(&probe);
900
901                (probe, token)
902            });
903
904            while probe.less_than(&expected_frontier) {
905                worker.step();
906            }
907
908            let mut probe_frontier = Antichain::new();
909            probe.with_frontier(|f| probe_frontier.extend(f.iter().cloned()));
910
911            probe_frontier
912        });
913
914        assert_eq!(res, Antichain::from_elem(expected_frontier));
915    }
916
917    /// Verifies that a `shard_source` will downgrade it's output frontier to
918    /// the given `as_of`. Even if there is no data/no snapshot available in the
919    /// shard.
920    ///
921    /// NOTE: This test is weird: if everything is good it will pass. If we
922    /// break the assumption that we test this will time out and we will notice.
923    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
924    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
925    async fn test_shard_source_explicit_initial_as_of() {
926        let persist_client = PersistClient::new_for_tests().await;
927
928        let expected_frontier = 42;
929        let shard_id = ShardId::new();
930
931        initialize_shard(
932            &persist_client,
933            shard_id,
934            Antichain::from_elem(expected_frontier),
935        )
936        .await;
937
938        let res = timely::execute::execute_directly(move |worker| {
939            let as_of = Antichain::from_elem(expected_frontier);
940            let until = Antichain::new();
941
942            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
943                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
944                    let transformer = move |_, descs, _| (descs, vec![]);
945                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
946                        outer,
947                        scope,
948                        "test_source",
949                        move || std::future::ready(persist_client.clone()),
950                        shard_id,
951                        Some(as_of), // We specify the as_of explicitly!
952                        SnapshotMode::Include,
953                        until,
954                        Some(transformer),
955                        Arc::new(
956                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
957                        ),
958                        Arc::new(
959                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
960                        ),
961                        FilterResult::keep_all,
962                        false.then_some(|| unreachable!()),
963                        async {},
964                        ErrorHandler::Halt("test"),
965                    );
966                    (stream.leave(outer), tokens)
967                });
968
969                let probe = ProbeHandle::new();
970                let _stream = stream.probe_with(&probe);
971
972                (probe, token)
973            });
974
975            while probe.less_than(&expected_frontier) {
976                worker.step();
977            }
978
979            let mut probe_frontier = Antichain::new();
980            probe.with_frontier(|f| probe_frontier.extend(f.iter().cloned()));
981
982            probe_frontier
983        });
984
985        assert_eq!(res, Antichain::from_elem(expected_frontier));
986    }
987
988    /// Hydrating an index over a shard with many fine-grained batches (the prod
989    /// case: a retained-history collection written at ~1/s, whose batches stay
990    /// unmerged because the held-back `since` blocks compaction) replays one
991    /// progress round per batch. With
992    /// `persist_source_hydration_frontier_coalesce_bytes` set, the source holds
993    /// those downgrades back while catching up to the hydration-time upper and
994    /// forwards them in a few larger steps instead.
995    ///
996    /// This writes `N_BATCHES` single-timestamp batches (compaction disabled so
997    /// they stay distinct, mirroring a held-back `since`) and runs the source
998    /// twice over the same shard, counting how many distinct output frontiers it
999    /// passes through. Disabled (the default) replays per batch; enabled with a
1000    /// budget larger than the whole replay collapses it to a single jump. Both
1001    /// must still reach the same final upper, so coalescing only changes
1002    /// frontier granularity, not how far the source gets.
1003    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1004    #[cfg_attr(miri, ignore)] // too slow
1005    async fn test_shard_source_hydration_frontier_coalesce() {
1006        const N_BATCHES: u64 = 64;
1007
1008        // Hydrate the shard from `as_of = 0` with the given coalesce budget and
1009        // return the largest timestamp whose parts were emitted. The source is
1010        // bounded at the upper, so a return at all proves it terminated without
1011        // stalling.
1012        async fn run(coalesce_bytes: usize) -> Option<u64> {
1013            let mut cache = PersistClientCache::new_no_metrics();
1014            // Keep the batches unmerged, so the source sees one batch per write
1015            // just as a retained-history shard does in prod.
1016            cache.cfg.compaction_enabled = false;
1017            cache
1018                .cfg
1019                .set_config(&SOURCE_HYDRATION_FRONTIER_COALESCE_BYTES, coalesce_bytes);
1020            let persist_client = cache
1021                .open(PersistLocation::new_in_mem())
1022                .await
1023                .expect("in-mem location is valid");
1024            let shard_id = ShardId::new();
1025
1026            let mut write = persist_client
1027                .open_writer::<String, String, u64, u64>(
1028                    shard_id,
1029                    Arc::new(StringSchema),
1030                    Arc::new(StringSchema),
1031                    Diagnostics::for_tests(),
1032                )
1033                .await
1034                .expect("invalid usage");
1035
1036            // One append per timestamp: `N_BATCHES` distinct batches sealing
1037            // `[0, N_BATCHES)`.
1038            for t in 0..N_BATCHES {
1039                let row = ((format!("k{t}"), format!("v{t}")), t, 1u64);
1040                write.expect_compare_and_append(&[row], t, t + 1).await;
1041            }
1042
1043            timely::execute::execute_directly(move |worker| {
1044                let (probe, receiver, _token) = worker.dataflow::<u64, _, _>(|outer| {
1045                    let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1046                        let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1047                            outer,
1048                            scope,
1049                            "test_source",
1050                            move || std::future::ready(persist_client.clone()),
1051                            shard_id,
1052                            Some(Antichain::from_elem(0)),
1053                            SnapshotMode::Include,
1054                            // Bound the source at the shard upper so it
1055                            // terminates once the replay is done.
1056                            Antichain::from_elem(N_BATCHES),
1057                            Some(move |_, descs, _| (descs, vec![])),
1058                            Arc::new(StringSchema),
1059                            Arc::new(StringSchema),
1060                            FilterResult::keep_all,
1061                            false.then_some(|| unreachable!()),
1062                            async {},
1063                            ErrorHandler::Halt("test"),
1064                        );
1065                        (stream.leave(outer), tokens)
1066                    });
1067                    let probe = ProbeHandle::new();
1068                    // Capture the source's output directly so every progress
1069                    // message is recorded, independent of how many we drain per
1070                    // worker step.
1071                    let receiver = stream.probe_with(&probe).capture();
1072                    (probe, receiver, token)
1073                });
1074
1075                // Step until the source closes its output (until == upper, so it
1076                // drops its capabilities once the replay completes).
1077                let deadline = Instant::now() + std::time::Duration::from_secs(60);
1078                while !probe.with_frontier(|f| f.is_empty()) {
1079                    assert!(Instant::now() < deadline, "timed out hydrating shard");
1080                    worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1081                }
1082
1083                // The largest `Messages` time is the highest timestamp whose
1084                // parts were emitted; it must reach `N_BATCHES - 1` regardless of
1085                // coalescing, because parts always flow per batch and only the
1086                // frontier downgrades are held back.
1087                let mut max_msg_time: Option<u64> = None;
1088                while let Ok(event) = receiver.try_recv() {
1089                    if let CaptureEvent::Messages(time, _) = event {
1090                        max_msg_time = max_msg_time.max(Some(time));
1091                    }
1092                }
1093                max_msg_time
1094            })
1095        }
1096
1097        // Coalescing disabled (default) and enabled with a budget larger than
1098        // the whole replay (a single 0 -> upper jump) must both consume every
1099        // batch and emit parts through the last timestamp. Coalescing changes
1100        // only frontier granularity, not the data emitted or how far we get;
1101        // the round reduction itself is covered by
1102        // `test_frontier_coalesce_decision`, since timely batches progress
1103        // across the dataflow edge in a fast in-mem replay.
1104        assert_eq!(run(0).await, Some(N_BATCHES - 1));
1105        assert_eq!(run(1 << 30).await, Some(N_BATCHES - 1));
1106    }
1107
1108    /// The round-reduction property, tested directly on the forward/coalesce
1109    /// decision so it is independent of timely's progress batching. Simulates
1110    /// replaying `n` unit batches from as-of 0 to upper `n`, each `bytes`
1111    /// encoded bytes, and counts how many progress downgrades get forwarded.
1112    #[mz_ore::test]
1113    fn test_frontier_coalesce_decision() {
1114        fn forwards(coalesce_target: u64, n: u64, bytes_per_batch: u64) -> usize {
1115            let replay_upper = n;
1116            let mut current = 0u64;
1117            let mut coalesced = 0u64;
1118            let mut forwarded = 0usize;
1119            for _ in 0..n {
1120                current += 1;
1121                coalesced += bytes_per_batch;
1122                let caught_up = replay_upper <= current;
1123                // Mirrors the forward/coalesce decision in `shard_source_descs`.
1124                let coalesce = coalesce_target > 0 && !caught_up && coalesced < coalesce_target;
1125                if !coalesce {
1126                    forwarded += 1;
1127                    coalesced = 0;
1128                }
1129            }
1130            forwarded
1131        }
1132
1133        // Disabled: one forward per batch (the per-batch storm).
1134        assert_eq!(forwards(0, 64, 10), 64);
1135        // Budget larger than the whole replay: a single forward at the upper.
1136        assert_eq!(forwards(1 << 30, 64, 10), 1);
1137        // Mid budget: forwards every ~target/bytes batches plus the final
1138        // catch-up forward, so strictly between the two extremes.
1139        let partial = forwards(100, 64, 10);
1140        assert!(
1141            partial > 1 && partial < 64,
1142            "expected partial coalescing, got {partial}"
1143        );
1144    }
1145
1146    /// Verifies that the source fetches and emits actual data: a batch written
1147    /// before the dataflow starts comes out as at least one `FetchedBlob`, and
1148    /// the output frontier reaches the shard's upper.
1149    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1150    #[cfg_attr(miri, ignore)] // too slow
1151    async fn test_shard_source_fetches_data() {
1152        let persist_client = PersistClient::new_for_tests().await;
1153        let shard_id = ShardId::new();
1154
1155        let mut write = persist_client
1156            .open_writer::<String, String, u64, u64>(
1157                shard_id,
1158                Arc::new(StringSchema),
1159                Arc::new(StringSchema),
1160                Diagnostics::for_tests(),
1161            )
1162            .await
1163            .expect("invalid usage");
1164        let data = [
1165            (("k1".to_owned(), "v1".to_owned()), 0u64, 1u64),
1166            (("k2".to_owned(), "v2".to_owned()), 1u64, 1u64),
1167        ];
1168        write.expect_compare_and_append(&data[..], 0, 5).await;
1169
1170        let expected_frontier = 5;
1171        let (blob_count, frontier) = timely::execute::execute_directly(move |worker| {
1172            let as_of = Antichain::from_elem(0);
1173            let until = Antichain::new();
1174
1175            let (capture, probe, token) = worker.dataflow::<u64, _, _>(|outer| {
1176                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1177                    let transformer = move |_, descs, _| (descs, vec![]);
1178                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1179                        outer,
1180                        scope,
1181                        "test_source",
1182                        move || std::future::ready(persist_client.clone()),
1183                        shard_id,
1184                        Some(as_of),
1185                        SnapshotMode::Include,
1186                        until,
1187                        Some(transformer),
1188                        Arc::new(StringSchema),
1189                        Arc::new(StringSchema),
1190                        FilterResult::keep_all,
1191                        false.then_some(|| unreachable!()),
1192                        async {},
1193                        ErrorHandler::Halt("test"),
1194                    );
1195                    (stream.leave(outer), tokens)
1196                });
1197
1198                let probe = ProbeHandle::new();
1199                let stream = stream.probe_with(&probe);
1200                (stream.capture(), probe, token)
1201            });
1202
1203            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1204            while probe.less_than(&expected_frontier) {
1205                assert!(
1206                    Instant::now() < deadline,
1207                    "timed out waiting for output frontier {expected_frontier}"
1208                );
1209                worker.step();
1210            }
1211            drop(token);
1212
1213            let mut blob_count = 0;
1214            while let Ok(event) = capture.try_recv() {
1215                if let CaptureEvent::Messages(_, msgs) = event {
1216                    blob_count += msgs.len();
1217                }
1218            }
1219            let mut frontier = Antichain::new();
1220            probe.with_frontier(|f| frontier.extend(f.iter().cloned()));
1221            (blob_count, frontier)
1222        });
1223
1224        assert!(blob_count >= 1, "expected at least one fetched blob");
1225        assert_eq!(frontier, Antichain::from_elem(expected_frontier));
1226    }
1227
1228    /// Verifies that dropping the source's tokens while it is running does not
1229    /// panic or wedge the worker: capabilities are released so the dataflow can
1230    /// shut down to the empty frontier.
1231    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1232    #[cfg_attr(miri, ignore)] // too slow
1233    async fn test_shard_source_shutdown_mid_stream() {
1234        let persist_client = PersistClient::new_for_tests().await;
1235        let shard_id = ShardId::new();
1236
1237        let mut write = persist_client
1238            .open_writer::<String, String, u64, u64>(
1239                shard_id,
1240                Arc::new(StringSchema),
1241                Arc::new(StringSchema),
1242                Diagnostics::for_tests(),
1243            )
1244            .await
1245            .expect("invalid usage");
1246        let data = [(("k1".to_owned(), "v1".to_owned()), 0u64, 1u64)];
1247        write.expect_compare_and_append(&data[..], 0, 5).await;
1248
1249        timely::execute::execute_directly(move |worker| {
1250            let as_of = Antichain::from_elem(0);
1251            // An empty `until` means the source would run forever if not shut
1252            // down by dropping its tokens.
1253            let until = Antichain::new();
1254
1255            let (probe, token) = worker.dataflow::<u64, _, _>(|outer| {
1256                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1257                    let transformer = move |_, descs, _| (descs, vec![]);
1258                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1259                        outer,
1260                        scope,
1261                        "test_source",
1262                        move || std::future::ready(persist_client.clone()),
1263                        shard_id,
1264                        Some(as_of),
1265                        SnapshotMode::Include,
1266                        until,
1267                        Some(transformer),
1268                        Arc::new(StringSchema),
1269                        Arc::new(StringSchema),
1270                        FilterResult::keep_all,
1271                        false.then_some(|| unreachable!()),
1272                        async {},
1273                        ErrorHandler::Halt("test"),
1274                    );
1275                    (stream.leave(outer), tokens)
1276                });
1277
1278                let probe = ProbeHandle::new();
1279                let _stream = stream.probe_with(&probe);
1280                (probe, token)
1281            });
1282
1283            // Step until the source has made progress, so shutdown happens while
1284            // the fetch machinery is live.
1285            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1286            while probe.less_than(&1) {
1287                assert!(Instant::now() < deadline, "timed out waiting for progress");
1288                worker.step();
1289            }
1290
1291            // Shut down and confirm the dataflow drains: with all tokens dropped,
1292            // the operators must release their capabilities and the frontier must
1293            // become empty.
1294            drop(token);
1295            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1296            loop {
1297                assert!(Instant::now() < deadline, "timed out waiting for shutdown");
1298                worker.step();
1299                if probe.with_frontier(|f| f.is_empty()) {
1300                    break;
1301                }
1302            }
1303        });
1304    }
1305
1306    /// Verifies that an unserveable `as_of` (the listing path) reports an error
1307    /// through the `ErrorHandler` and freezes the source: the output frontier
1308    /// stays at the requested `as_of` and the worker does not panic.
1309    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1310    #[cfg_attr(miri, ignore)] // too slow
1311    async fn test_shard_source_error_freeze() {
1312        let persist_client = PersistClient::new_for_tests().await;
1313        let shard_id = ShardId::new();
1314
1315        // Write data so the shard's upper is past the as_of (otherwise
1316        // `snapshot` blocks waiting for the upper instead of erroring on the
1317        // since), then advance the since past the as_of we'll request.
1318        let mut write = persist_client
1319            .open_writer::<String, String, u64, u64>(
1320                shard_id,
1321                Arc::new(StringSchema),
1322                Arc::new(StringSchema),
1323                Diagnostics::for_tests(),
1324            )
1325            .await
1326            .expect("invalid usage");
1327        let data = [(("k1".to_owned(), "v1".to_owned()), 0u64, 1u64)];
1328        write.expect_compare_and_append(&data[..], 0, 5).await;
1329        initialize_shard(&persist_client, shard_id, Antichain::from_elem(3)).await;
1330
1331        let (errored, frontier) = timely::execute::execute_directly(move |worker| {
1332            let as_of = Antichain::from_elem(1);
1333            let until = Antichain::new();
1334
1335            let errored = Rc::new(std::cell::Cell::new(false));
1336            let error_handler = ErrorHandler::signal({
1337                let errored = Rc::clone(&errored);
1338                move |_err| errored.set(true)
1339            });
1340
1341            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
1342                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1343                    let transformer = move |_, descs, _| (descs, vec![]);
1344                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1345                        outer,
1346                        scope,
1347                        "test_source",
1348                        move || std::future::ready(persist_client.clone()),
1349                        shard_id,
1350                        Some(as_of),
1351                        SnapshotMode::Include,
1352                        until,
1353                        Some(transformer),
1354                        Arc::new(StringSchema),
1355                        Arc::new(StringSchema),
1356                        FilterResult::keep_all,
1357                        false.then_some(|| unreachable!()),
1358                        async {},
1359                        error_handler,
1360                    );
1361                    (stream.leave(outer), tokens)
1362                });
1363
1364                let probe = ProbeHandle::new();
1365                let _stream = stream.probe_with(&probe);
1366                (probe, token)
1367            });
1368
1369            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1370            while !errored.get() {
1371                assert!(Instant::now() < deadline, "timed out waiting for error");
1372                worker.step();
1373            }
1374            // Keep stepping; the source must stay frozen at the as_of.
1375            for _ in 0..100 {
1376                worker.step();
1377            }
1378
1379            let mut frontier = Antichain::new();
1380            probe.with_frontier(|f| frontier.extend(f.iter().cloned()));
1381            (errored.get(), frontier)
1382        });
1383
1384        assert!(errored);
1385        assert_eq!(frontier, Antichain::from_elem(1));
1386    }
1387
1388    /// Regression test for the `shard_source_fetch` freeze path: a blob that
1389    /// goes missing while *fetching* (the listing path is covered by
1390    /// `test_shard_source_error_freeze`) must freeze the output frontier at the
1391    /// missing part and report the error, never advancing past data never
1392    /// emitted.
1393    ///
1394    /// We delete the first batch's blob, which is read by the snapshot at
1395    /// `as_of = 0`. Its fetch fails; the later batches (t=1, t=2) fetch fine. We
1396    /// step until the dataflow quiesces (with brief parks so the tokio fetch
1397    /// task finishes), so the later results are produced, then assert the error
1398    /// fired and the frontier stayed at the missing part.
1399    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1400    #[cfg_attr(miri, ignore)] // too slow
1401    async fn test_shard_source_fetch_error_freeze() {
1402        // Force writes to real blobs (inline parts have no blob to delete) and
1403        // disable compaction so the three batches stay distinct and deletable.
1404        let mut cache = PersistClientCache::new_no_metrics();
1405        cache.cfg.compaction_enabled = false;
1406        cache.cfg.set_config(&INLINE_WRITES_SINGLE_MAX_BYTES, 0);
1407        cache.cfg.set_config(&INLINE_WRITES_TOTAL_MAX_BYTES, 0);
1408        let persist_client = cache
1409            .open(PersistLocation::new_in_mem())
1410            .await
1411            .expect("in-mem location is valid");
1412        let shard_id = ShardId::new();
1413        // Clones of a `PersistClient` share the blob `Arc`, so deleting via this
1414        // handle is visible to the reader the source opens.
1415        let blob = Arc::clone(&persist_client.blob);
1416
1417        let mut write = persist_client
1418            .open_writer::<String, String, u64, u64>(
1419                shard_id,
1420                Arc::new(StringSchema),
1421                Arc::new(StringSchema),
1422                Diagnostics::for_tests(),
1423            )
1424            .await
1425            .expect("invalid usage");
1426
1427        // The data-part (non-rollup) blob keys currently present.
1428        async fn batch_keys(blob: &dyn Blob) -> std::collections::BTreeSet<String> {
1429            let mut keys = std::collections::BTreeSet::new();
1430            blob.list_keys_and_metadata("", &mut |meta| {
1431                if let Ok((_, PartialBlobKey::Batch(..))) = BlobKey::parse_ids(meta.key) {
1432                    keys.insert(meta.key.to_owned());
1433                }
1434            })
1435            .await
1436            .expect("list keys");
1437            keys
1438        }
1439
1440        let row = |t: u64| ((format!("k{t}"), format!("v{t}")), t, 1u64);
1441        let before = batch_keys(blob.as_ref()).await;
1442        write.expect_compare_and_append(&[row(0)], 0, 1).await;
1443        let after = batch_keys(blob.as_ref()).await;
1444        write.expect_compare_and_append(&[row(1)], 1, 2).await;
1445        write.expect_compare_and_append(&[row(2)], 2, 3).await;
1446
1447        // Delete exactly the first (t=0) batch's data part(s); the snapshot at
1448        // as_of=0 reads it.
1449        let missing: Vec<_> = after.difference(&before).cloned().collect();
1450        assert!(!missing.is_empty(), "first batch wrote no blob part");
1451        for key in &missing {
1452            blob.delete(key).await.expect("delete");
1453        }
1454
1455        let frontier = timely::execute::execute_directly(move |worker| {
1456            let errored = Rc::new(std::cell::Cell::new(false));
1457            let error_handler = ErrorHandler::signal({
1458                let errored = Rc::clone(&errored);
1459                move |_err| errored.set(true)
1460            });
1461
1462            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
1463                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1464                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1465                        outer,
1466                        scope,
1467                        "test_source",
1468                        move || std::future::ready(persist_client.clone()),
1469                        shard_id,
1470                        Some(Antichain::from_elem(0)),
1471                        SnapshotMode::Include,
1472                        Antichain::new(),
1473                        Some(move |_, descs, _| (descs, vec![])),
1474                        Arc::new(StringSchema),
1475                        Arc::new(StringSchema),
1476                        FilterResult::keep_all,
1477                        false.then_some(|| unreachable!()),
1478                        async {},
1479                        error_handler,
1480                    );
1481                    (stream.leave(outer), tokens)
1482                });
1483                let probe = ProbeHandle::new();
1484                stream.probe_with(&probe);
1485                (probe, token)
1486            });
1487
1488            // Step until the fetch error fires, then step until the dataflow
1489            // quiesces, with brief parks so the tokio fetch task can finish.
1490            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1491            while !errored.get() {
1492                assert!(
1493                    Instant::now() < deadline,
1494                    "timed out waiting for fetch error"
1495                );
1496                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1497            }
1498            let mut last = probe.with_frontier(|f| f.to_owned());
1499            let mut stable = 0;
1500            while stable < 100 {
1501                assert!(Instant::now() < deadline, "timed out waiting for quiesce");
1502                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1503                let now = probe.with_frontier(|f| f.to_owned());
1504                if now == last {
1505                    stable += 1;
1506                } else {
1507                    stable = 0;
1508                    last = now;
1509                }
1510            }
1511            last
1512        });
1513
1514        // Frozen at the missing part (t=0); the bug advanced this past it.
1515        assert_eq!(frontier, Antichain::from_elem(0));
1516    }
1517
1518    /// With `persist_source_fetch_concurrency > 1` the source still fetches
1519    /// every part and reaches the shard upper. The frontier can only reach the
1520    /// upper once every part's fetch has completed, so this proves the
1521    /// concurrent path loses nothing even though results complete out of order.
1522    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1523    #[cfg_attr(miri, ignore)] // too slow
1524    async fn test_shard_source_fetch_concurrent() {
1525        const N_BATCHES: u64 = 16;
1526
1527        let mut cache = PersistClientCache::new_no_metrics();
1528        cache.cfg.compaction_enabled = false;
1529        cache.cfg.set_config(&SOURCE_FETCH_CONCURRENCY, 8);
1530        let persist_client = cache
1531            .open(PersistLocation::new_in_mem())
1532            .await
1533            .expect("in-mem location is valid");
1534        let shard_id = ShardId::new();
1535
1536        let mut write = persist_client
1537            .open_writer::<String, String, u64, u64>(
1538                shard_id,
1539                Arc::new(StringSchema),
1540                Arc::new(StringSchema),
1541                Diagnostics::for_tests(),
1542            )
1543            .await
1544            .expect("invalid usage");
1545        for t in 0..N_BATCHES {
1546            let row = ((format!("k{t}"), format!("v{t}")), t, 1u64);
1547            write.expect_compare_and_append(&[row], t, t + 1).await;
1548        }
1549
1550        let (blob_count, max_time) = timely::execute::execute_directly(move |worker| {
1551            let (capture, probe, token) = worker.dataflow::<u64, _, _>(|outer| {
1552                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1553                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1554                        outer,
1555                        scope,
1556                        "test_source",
1557                        move || std::future::ready(persist_client.clone()),
1558                        shard_id,
1559                        Some(Antichain::from_elem(0)),
1560                        SnapshotMode::Include,
1561                        Antichain::from_elem(N_BATCHES),
1562                        Some(move |_, descs, _| (descs, vec![])),
1563                        Arc::new(StringSchema),
1564                        Arc::new(StringSchema),
1565                        FilterResult::keep_all,
1566                        false.then_some(|| unreachable!()),
1567                        async {},
1568                        ErrorHandler::Halt("test"),
1569                    );
1570                    (stream.leave(outer), tokens)
1571                });
1572                let probe = ProbeHandle::new();
1573                let stream = stream.probe_with(&probe);
1574                (stream.capture(), probe, token)
1575            });
1576
1577            // The source is bounded at the upper, so its frontier empties only
1578            // once every part's fetch has completed; reaching the empty frontier
1579            // proves the concurrent path lost nothing.
1580            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1581            while !probe.with_frontier(|f| f.is_empty()) {
1582                assert!(
1583                    Instant::now() < deadline,
1584                    "timed out waiting for completion"
1585                );
1586                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1587            }
1588            drop(token);
1589
1590            let mut blob_count = 0;
1591            let mut max_time: Option<u64> = None;
1592            while let Ok(event) = capture.try_recv() {
1593                if let CaptureEvent::Messages(time, msgs) = event {
1594                    blob_count += msgs.len();
1595                    max_time = max_time.max(Some(time));
1596                }
1597            }
1598            (blob_count, max_time)
1599        });
1600
1601        assert!(blob_count >= 1, "expected at least one fetched blob");
1602        // Parts were emitted through the last timestamp.
1603        assert_eq!(max_time, Some(N_BATCHES - 1));
1604    }
1605
1606    /// Fetch-path freeze under concurrency: with several fetches in flight, a
1607    /// missing *middle* batch must still freeze the frontier at that batch, even
1608    /// though later batches fetch fine and may complete before the error is
1609    /// observed. This is the out-of-order analogue of
1610    /// `test_shard_source_fetch_error_freeze`; it exercises the time-keyed
1611    /// capability bookkeeping the concurrent path relies on.
1612    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1613    #[cfg_attr(miri, ignore)] // too slow
1614    async fn test_shard_source_fetch_concurrent_error_freeze() {
1615        const N_BATCHES: u64 = 12;
1616        const MISSING_TS: u64 = 5;
1617
1618        let mut cache = PersistClientCache::new_no_metrics();
1619        cache.cfg.compaction_enabled = false;
1620        cache.cfg.set_config(&INLINE_WRITES_SINGLE_MAX_BYTES, 0);
1621        cache.cfg.set_config(&INLINE_WRITES_TOTAL_MAX_BYTES, 0);
1622        cache.cfg.set_config(&SOURCE_FETCH_CONCURRENCY, 8);
1623        let persist_client = cache
1624            .open(PersistLocation::new_in_mem())
1625            .await
1626            .expect("in-mem location is valid");
1627        let shard_id = ShardId::new();
1628        let blob = Arc::clone(&persist_client.blob);
1629
1630        let mut write = persist_client
1631            .open_writer::<String, String, u64, u64>(
1632                shard_id,
1633                Arc::new(StringSchema),
1634                Arc::new(StringSchema),
1635                Diagnostics::for_tests(),
1636            )
1637            .await
1638            .expect("invalid usage");
1639
1640        async fn batch_keys(blob: &dyn Blob) -> std::collections::BTreeSet<String> {
1641            let mut keys = std::collections::BTreeSet::new();
1642            blob.list_keys_and_metadata("", &mut |meta| {
1643                if let Ok((_, PartialBlobKey::Batch(..))) = BlobKey::parse_ids(meta.key) {
1644                    keys.insert(meta.key.to_owned());
1645                }
1646            })
1647            .await
1648            .expect("list keys");
1649            keys
1650        }
1651
1652        // Write each batch, snapshotting the blob keys around the missing one so
1653        // we can delete exactly its part(s).
1654        let mut missing = Vec::new();
1655        for t in 0..N_BATCHES {
1656            let before = batch_keys(blob.as_ref()).await;
1657            let row = ((format!("k{t}"), format!("v{t}")), t, 1u64);
1658            write.expect_compare_and_append(&[row], t, t + 1).await;
1659            if t == MISSING_TS {
1660                let after = batch_keys(blob.as_ref()).await;
1661                missing = after.difference(&before).cloned().collect();
1662            }
1663        }
1664        assert!(!missing.is_empty(), "missing batch wrote no blob part");
1665        for key in &missing {
1666            blob.delete(key).await.expect("delete");
1667        }
1668
1669        let frontier = timely::execute::execute_directly(move |worker| {
1670            let errored = Rc::new(std::cell::Cell::new(false));
1671            let error_handler = ErrorHandler::signal({
1672                let errored = Rc::clone(&errored);
1673                move |_err| errored.set(true)
1674            });
1675
1676            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
1677                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1678                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1679                        outer,
1680                        scope,
1681                        "test_source",
1682                        move || std::future::ready(persist_client.clone()),
1683                        shard_id,
1684                        Some(Antichain::from_elem(0)),
1685                        SnapshotMode::Include,
1686                        Antichain::from_elem(N_BATCHES),
1687                        Some(move |_, descs, _| (descs, vec![])),
1688                        Arc::new(StringSchema),
1689                        Arc::new(StringSchema),
1690                        FilterResult::keep_all,
1691                        false.then_some(|| unreachable!()),
1692                        async {},
1693                        error_handler,
1694                    );
1695                    (stream.leave(outer), tokens)
1696                });
1697                let probe = ProbeHandle::new();
1698                stream.probe_with(&probe);
1699                (probe, token)
1700            });
1701
1702            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1703            while !errored.get() {
1704                assert!(
1705                    Instant::now() < deadline,
1706                    "timed out waiting for fetch error"
1707                );
1708                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1709            }
1710            let mut last = probe.with_frontier(|f| f.to_owned());
1711            let mut stable = 0;
1712            while stable < 100 {
1713                assert!(Instant::now() < deadline, "timed out waiting for quiesce");
1714                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1715                let now = probe.with_frontier(|f| f.to_owned());
1716                if now == last {
1717                    stable += 1;
1718                } else {
1719                    stable = 0;
1720                    last = now;
1721                }
1722            }
1723            last
1724        });
1725
1726        // Frozen at the missing batch despite later batches fetching fine
1727        // concurrently.
1728        assert_eq!(frontier, Antichain::from_elem(MISSING_TS));
1729    }
1730
1731    async fn initialize_shard(
1732        persist_client: &PersistClient,
1733        shard_id: ShardId,
1734        since: Antichain<u64>,
1735    ) {
1736        let mut read_handle = persist_client
1737            .open_leased_reader::<String, String, u64, u64>(
1738                shard_id,
1739                Arc::new(<std::string::String as mz_persist_types::Codec>::Schema::default()),
1740                Arc::new(<std::string::String as mz_persist_types::Codec>::Schema::default()),
1741                Diagnostics::for_tests(),
1742                true,
1743            )
1744            .await
1745            .expect("invalid usage");
1746
1747        read_handle.downgrade_since(&since).await;
1748    }
1749}