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