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                                    filter_fn(&stats.decode(), current_frontier.borrow())
538                                });
539                            should_fetch
540                        }
541                        BatchPart::Inline { .. } => FilterResult::Keep,
542                    };
543                    // Apply the filter: discard or substitute the part if required.
544                    let bytes = u64::cast_from(part_desc.encoded_size_bytes());
545                    match filter_result {
546                        FilterResult::Keep => {
547                            audit_budget_bytes = audit_budget_bytes.saturating_add(bytes);
548                        }
549                        FilterResult::Discard => {
550                            metrics.pushdown.parts_filtered_count.inc();
551                            metrics.pushdown.parts_filtered_bytes.inc_by(bytes);
552                            let should_audit = match &part_desc.part {
553                                BatchPart::Hollow(x) => {
554                                    let mut h = DefaultHasher::new();
555                                    x.key.hash(&mut h);
556                                    usize::cast_from(h.finish()) % 100
557                                        < STATS_AUDIT_PERCENT.get(&cfg)
558                                }
559                                BatchPart::Inline { .. } => false,
560                            };
561                            if should_audit && bytes < audit_budget_bytes {
562                                audit_budget_bytes -= bytes;
563                                metrics.pushdown.parts_audited_count.inc();
564                                metrics.pushdown.parts_audited_bytes.inc_by(bytes);
565                                part_desc.request_filter_pushdown_audit();
566                            } else {
567                                debug!(
568                                    "skipping part because of stats filter {:?}",
569                                    part_desc.part.stats()
570                                );
571                                continue;
572                            }
573                        }
574                        FilterResult::ReplaceWith { key, val } => {
575                            part_desc.maybe_optimize(&cfg, key, val);
576                            audit_budget_bytes = audit_budget_bytes.saturating_add(bytes);
577                        }
578                    }
579                    let bytes = u64::cast_from(part_desc.encoded_size_bytes());
580                    if part_desc.part.is_inline() {
581                        metrics.pushdown.parts_inline_count.inc();
582                        metrics.pushdown.parts_inline_bytes.inc_by(bytes);
583                    } else {
584                        metrics.pushdown.parts_fetched_count.inc();
585                        metrics.pushdown.parts_fetched_bytes.inc_by(bytes);
586                    }
587                }
588
589                // Give the part to a random worker. This isn't round robin in an attempt to avoid
590                // skew issues: if your parts alternate size large, small, then you'll end up only
591                // using half of your workers.
592                //
593                // There's certainly some other things we could be doing instead here, but this has
594                // seemed to work okay so far. Continue to revisit as necessary.
595                let worker_idx = usize::cast_from(Instant::now().hashed()) % num_workers;
596                batch_bytes =
597                    batch_bytes.saturating_add(u64::cast_from(part_desc.encoded_size_bytes()));
598                let (part, lease) = part_desc.into_exchangeable_part();
599                leases.borrow_mut().push_at(current_ts.clone(), lease);
600                descs_output.give(&session_cap, (worker_idx, part));
601            }
602
603            current_frontier.join_assign(&progress);
604            coalesced_bytes = coalesced_bytes.saturating_add(batch_bytes);
605
606            // Coalesce the frontier downgrade while still catching up to `replay_upper`
607            // and below the byte budget. Parts carry their real timestamps regardless, so
608            // holding the frontier back only batches downstream progress rounds. Once live
609            // (caught up to `replay_upper`) or with coalescing disabled we forward every
610            // batch, keeping steady-state tracking tight for consumers like `persist_sink`.
611            let caught_up = PartialOrder::less_equal(&replay_upper, &current_frontier);
612            let coalesce = coalesce_target > 0 && !caught_up && coalesced_bytes < coalesce_target;
613            if !coalesce {
614                coalesced_bytes = 0;
615                cap_set.downgrade(current_frontier.iter());
616            }
617        }
618    });
619
620    (descs_stream, shutdown_button.press_on_drop())
621}
622
623pub(crate) fn shard_source_fetch<'inner, K, V, T, D, TInner>(
624    descs: StreamVec<'inner, TInner, (usize, ExchangeableBatchPart<T>)>,
625    name: &str,
626    client: impl Future<Output = PersistClient> + Send + 'static,
627    shard_id: ShardId,
628    key_schema: Arc<K::Schema>,
629    val_schema: Arc<V::Schema>,
630    is_transient: bool,
631    error_handler: ErrorHandler,
632) -> (
633    StreamVec<'inner, TInner, FetchedBlob<K, V, T, D>>,
634    StreamVec<'inner, TInner, Infallible>,
635    PressOnDropButton,
636)
637where
638    K: Debug + Codec,
639    V: Debug + Codec,
640    T: Timestamp + Lattice + Codec64 + Sync,
641    D: Monoid + Codec64 + Send + Sync,
642    TInner: Timestamp + Refines<T>,
643{
644    let mut builder =
645        AsyncOperatorBuilder::new(format!("shard_source_fetch({})", name), descs.scope());
646    let (fetched_output, fetched_stream) = builder.new_output::<CapacityContainerBuilder<_>>();
647    let (completed_fetches_output, completed_fetches_stream) =
648        builder.new_output::<CapacityContainerBuilder<Vec<Infallible>>>();
649    let mut descs_input = builder.new_input_for_many(
650        descs,
651        Exchange::new(|&(i, _): &(usize, _)| u64::cast_from(i)),
652        [&fetched_output, &completed_fetches_output],
653    );
654    let name_owned = name.to_owned();
655
656    let shutdown_button = builder.build(move |_capabilities| async move {
657        // Open the fetcher in a task to defend against an adversarial schedule
658        // delaying its background work, and read the concurrency dyncfg while we
659        // hold the client. See the equivalent reasoning in `shard_source_descs`.
660        let (fetcher, max_concurrency) =
661            mz_ore::task::spawn(|| format!("shard_source_fetch({})", name_owned), {
662                let diagnostics = Diagnostics {
663                    shard_name: name_owned.clone(),
664                    handle_purpose: format!("shard_source_fetch batch fetcher {}", name_owned),
665                };
666                async move {
667                    let client = client.await;
668                    // Up to this many part fetches run concurrently to amortize
669                    // the blob-store round-trip, which dominates when there are
670                    // many small parts. Results are keyed by time below, so
671                    // completions in any order are fine; total in-flight bytes
672                    // stay bounded by the fetch semaphore inside
673                    // `fetch_leased_part`.
674                    let max_concurrency = SOURCE_FETCH_CONCURRENCY.get(client.dyncfgs()).max(1);
675                    let fetcher = client
676                        .create_batch_fetcher::<K, V, T, D>(
677                            shard_id,
678                            key_schema,
679                            val_schema,
680                            is_transient,
681                            diagnostics,
682                        )
683                        .await
684                        .expect("shard codecs should not change");
685                    (fetcher, max_concurrency)
686                }
687            })
688            .await;
689
690        // Fetch one part on a per-call clone of the fetcher (cheap: shares the
691        // schema cache), carrying the part's input capabilities through so they
692        // come back with the result. The missing-blob diagnostics round-trip
693        // happens inside the future, so the error surfaces only after the fetch
694        // has truly failed.
695        let fetch_one = |caps: [Capability<TInner>; 2], part: ExchangeableBatchPart<T>| {
696            let mut fetcher = fetcher.clone();
697            async move {
698                let reader_id = part.reader_id().clone();
699                let fetched = fetcher
700                    .fetch_leased_part(part)
701                    .await
702                    .expect("shard_id should match across all workers");
703                let fetched = match fetched {
704                    Ok(fetched) => Ok(fetched),
705                    Err(blob_key) => {
706                        // Ideally, readers should never encounter a missing blob. They place a
707                        // seqno hold as they consume their snapshot/listen, preventing any blobs
708                        // they need from being deleted by garbage collection, and all blob
709                        // implementations are linearizable so there should be no possibility of
710                        // stale reads.
711                        //
712                        // However, it is possible for a lease to expire given a sustained period
713                        // of downtime, which could allow parts we expect to exist to be
714                        // deleted... at which point our best option is to request a restart.
715                        // Check the state of the minting reader's lease to tell the two cases
716                        // apart.
717                        let diagnostics = fetcher.missing_blob_diagnostics(&reader_id).await;
718                        Err(anyhow!(
719                            "batch fetcher could not fetch batch part {}: {}",
720                            blob_key,
721                            diagnostics
722                        ))
723                    }
724                };
725                (caps, fetched)
726            }
727        };
728
729        // Descs accepted from the input but not yet handed to a fetch, FIFO.
730        // Each carries its input capabilities (data + completed-fetches), so
731        // buffering here holds no progress hostage; it only bounds how many
732        // fetches run at once (and thus how many parts are resident in memory).
733        // Timely tracks the frontier through these capabilities: it advances
734        // past a time only once every fetch minted at that time has completed
735        // and dropped its clones, releasing the parts' leases on the chosen
736        // worker via the completed-fetches feedback. Carrying capabilities
737        // rather than a separate time-keyed map makes correctness independent of
738        // the order results come back in.
739        let mut pending: VecDeque<([Capability<TInner>; 2], ExchangeableBatchPart<T>)> =
740            VecDeque::new();
741        let mut in_flight = FuturesUnordered::new();
742        let mut input_done = false;
743
744        loop {
745            // Start fetches up to the concurrency cap.
746            while in_flight.len() < max_concurrency {
747                let Some((caps, part)) = pending.pop_front() else {
748                    break;
749                };
750                in_flight.push(fetch_one(caps, part));
751            }
752
753            tokio::select! {
754                // Emit completed fetches first, so `in_flight` drains and we do
755                // not hold more than `max_concurrency` parts in memory.
756                biased;
757                Some(([cap, _], fetched)) = in_flight.next(), if !in_flight.is_empty() => {
758                    match fetched {
759                        Ok(fetched) => fetched_output.give(&cap, fetched),
760                        Err(e) => {
761                            // `report_and_stop` never returns, freezing the
762                            // operator: `cap` (and every other in-flight and
763                            // pending capability) stays held, so the data frontier
764                            // never advances past the part we failed to emit.
765                            error_handler.report_and_stop(e).await;
766                        }
767                    }
768                }
769                // Accept new descs while the input is live. Their fetches are
770                // throttled by the `pending` queue above, not here.
771                event = descs_input.next(), if !input_done => {
772                    match event {
773                        Some(Event::Data(caps, data)) => {
774                            // `LeasedBatchPart`es cannot be dropped at this point
775                            // w/o panicking, so swap them to an owned version.
776                            for (_idx, part) in data {
777                                pending.push_back((caps.clone(), part));
778                            }
779                        }
780                        Some(Event::Progress(_)) => {}
781                        None => input_done = true,
782                    }
783                }
784                // Input is exhausted and no fetches remain: we're done.
785                else => break,
786            }
787        }
788    });
789
790    (
791        fetched_stream,
792        completed_fetches_stream,
793        shutdown_button.press_on_drop(),
794    )
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use std::sync::Arc;
801
802    use mz_persist::location::{Blob, SeqNo};
803    use mz_persist_types::codec_impls::StringSchema;
804    use timely::dataflow::operators::Leave;
805    use timely::dataflow::operators::Probe;
806    use timely::dataflow::operators::capture::{Capture, Event as CaptureEvent};
807    use timely::dataflow::operators::probe::Handle as ProbeHandle;
808    use timely::progress::Antichain;
809
810    use crate::batch::{INLINE_WRITES_SINGLE_MAX_BYTES, INLINE_WRITES_TOTAL_MAX_BYTES};
811    use crate::cache::PersistClientCache;
812    use crate::internal::paths::{BlobKey, PartialBlobKey};
813    use crate::operators::shard_source::shard_source;
814    use crate::{Diagnostics, PersistLocation, ShardId};
815
816    #[mz_ore::test]
817    fn test_lease_manager() {
818        let lease = Lease::new(SeqNo::minimum());
819        let mut manager = LeaseManager::new();
820        for t in 0u64..10 {
821            manager.push_at(t, lease.clone());
822        }
823        assert_eq!(lease.count(), 11);
824        manager.advance_to(AntichainRef::new(&[5]));
825        assert_eq!(lease.count(), 6);
826        manager.advance_to(AntichainRef::new(&[3]));
827        assert_eq!(lease.count(), 6);
828        manager.advance_to(AntichainRef::new(&[9]));
829        assert_eq!(lease.count(), 2);
830        manager.advance_to(AntichainRef::new(&[10]));
831        assert_eq!(lease.count(), 1);
832    }
833
834    /// Verifies that a `shard_source` will downgrade it's output frontier to
835    /// the `since` of the shard when no explicit `as_of` is given. Even if
836    /// there is no data/no snapshot available in the
837    /// shard.
838    ///
839    /// NOTE: This test is weird: if everything is good it will pass. If we
840    /// break the assumption that we test this will time out and we will notice.
841    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
842    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
843    async fn test_shard_source_implicit_initial_as_of() {
844        let persist_client = PersistClient::new_for_tests().await;
845
846        let expected_frontier = 42;
847        let shard_id = ShardId::new();
848
849        initialize_shard(
850            &persist_client,
851            shard_id,
852            Antichain::from_elem(expected_frontier),
853        )
854        .await;
855
856        let res = timely::execute::execute_directly(move |worker| {
857            let until = Antichain::new();
858
859            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
860                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
861                    let transformer = move |_, descs, _| (descs, vec![]);
862                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
863                        outer,
864                        scope,
865                        "test_source",
866                        move || std::future::ready(persist_client.clone()),
867                        shard_id,
868                        None, // No explicit as_of!
869                        SnapshotMode::Include,
870                        until,
871                        Some(transformer),
872                        Arc::new(
873                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
874                        ),
875                        Arc::new(
876                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
877                        ),
878                        FilterResult::keep_all,
879                        false.then_some(|| unreachable!()),
880                        async {},
881                        ErrorHandler::Halt("test"),
882                    );
883                    (stream.leave(outer), tokens)
884                });
885
886                let probe = ProbeHandle::new();
887                let _stream = stream.probe_with(&probe);
888
889                (probe, token)
890            });
891
892            while probe.less_than(&expected_frontier) {
893                worker.step();
894            }
895
896            let mut probe_frontier = Antichain::new();
897            probe.with_frontier(|f| probe_frontier.extend(f.iter().cloned()));
898
899            probe_frontier
900        });
901
902        assert_eq!(res, Antichain::from_elem(expected_frontier));
903    }
904
905    /// Verifies that a `shard_source` will downgrade it's output frontier to
906    /// the given `as_of`. Even if there is no data/no snapshot available in the
907    /// shard.
908    ///
909    /// NOTE: This test is weird: if everything is good it will pass. If we
910    /// break the assumption that we test this will time out and we will notice.
911    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
912    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
913    async fn test_shard_source_explicit_initial_as_of() {
914        let persist_client = PersistClient::new_for_tests().await;
915
916        let expected_frontier = 42;
917        let shard_id = ShardId::new();
918
919        initialize_shard(
920            &persist_client,
921            shard_id,
922            Antichain::from_elem(expected_frontier),
923        )
924        .await;
925
926        let res = timely::execute::execute_directly(move |worker| {
927            let as_of = Antichain::from_elem(expected_frontier);
928            let until = Antichain::new();
929
930            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
931                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
932                    let transformer = move |_, descs, _| (descs, vec![]);
933                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
934                        outer,
935                        scope,
936                        "test_source",
937                        move || std::future::ready(persist_client.clone()),
938                        shard_id,
939                        Some(as_of), // We specify the as_of explicitly!
940                        SnapshotMode::Include,
941                        until,
942                        Some(transformer),
943                        Arc::new(
944                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
945                        ),
946                        Arc::new(
947                            <std::string::String as mz_persist_types::Codec>::Schema::default(),
948                        ),
949                        FilterResult::keep_all,
950                        false.then_some(|| unreachable!()),
951                        async {},
952                        ErrorHandler::Halt("test"),
953                    );
954                    (stream.leave(outer), tokens)
955                });
956
957                let probe = ProbeHandle::new();
958                let _stream = stream.probe_with(&probe);
959
960                (probe, token)
961            });
962
963            while probe.less_than(&expected_frontier) {
964                worker.step();
965            }
966
967            let mut probe_frontier = Antichain::new();
968            probe.with_frontier(|f| probe_frontier.extend(f.iter().cloned()));
969
970            probe_frontier
971        });
972
973        assert_eq!(res, Antichain::from_elem(expected_frontier));
974    }
975
976    /// Hydrating an index over a shard with many fine-grained batches (the prod
977    /// case: a retained-history collection written at ~1/s, whose batches stay
978    /// unmerged because the held-back `since` blocks compaction) replays one
979    /// progress round per batch. With
980    /// `persist_source_hydration_frontier_coalesce_bytes` set, the source holds
981    /// those downgrades back while catching up to the hydration-time upper and
982    /// forwards them in a few larger steps instead.
983    ///
984    /// This writes `N_BATCHES` single-timestamp batches (compaction disabled so
985    /// they stay distinct, mirroring a held-back `since`) and runs the source
986    /// twice over the same shard, counting how many distinct output frontiers it
987    /// passes through. Disabled (the default) replays per batch; enabled with a
988    /// budget larger than the whole replay collapses it to a single jump. Both
989    /// must still reach the same final upper, so coalescing only changes
990    /// frontier granularity, not how far the source gets.
991    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
992    #[cfg_attr(miri, ignore)] // too slow
993    async fn test_shard_source_hydration_frontier_coalesce() {
994        const N_BATCHES: u64 = 64;
995
996        // Hydrate the shard from `as_of = 0` with the given coalesce budget and
997        // return the largest timestamp whose parts were emitted. The source is
998        // bounded at the upper, so a return at all proves it terminated without
999        // stalling.
1000        async fn run(coalesce_bytes: usize) -> Option<u64> {
1001            let mut cache = PersistClientCache::new_no_metrics();
1002            // Keep the batches unmerged, so the source sees one batch per write
1003            // just as a retained-history shard does in prod.
1004            cache.cfg.compaction_enabled = false;
1005            cache
1006                .cfg
1007                .set_config(&SOURCE_HYDRATION_FRONTIER_COALESCE_BYTES, coalesce_bytes);
1008            let persist_client = cache
1009                .open(PersistLocation::new_in_mem())
1010                .await
1011                .expect("in-mem location is valid");
1012            let shard_id = ShardId::new();
1013
1014            let mut write = persist_client
1015                .open_writer::<String, String, u64, u64>(
1016                    shard_id,
1017                    Arc::new(StringSchema),
1018                    Arc::new(StringSchema),
1019                    Diagnostics::for_tests(),
1020                )
1021                .await
1022                .expect("invalid usage");
1023
1024            // One append per timestamp: `N_BATCHES` distinct batches sealing
1025            // `[0, N_BATCHES)`.
1026            for t in 0..N_BATCHES {
1027                let row = ((format!("k{t}"), format!("v{t}")), t, 1u64);
1028                write.expect_compare_and_append(&[row], t, t + 1).await;
1029            }
1030
1031            timely::execute::execute_directly(move |worker| {
1032                let (probe, receiver, _token) = worker.dataflow::<u64, _, _>(|outer| {
1033                    let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1034                        let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1035                            outer,
1036                            scope,
1037                            "test_source",
1038                            move || std::future::ready(persist_client.clone()),
1039                            shard_id,
1040                            Some(Antichain::from_elem(0)),
1041                            SnapshotMode::Include,
1042                            // Bound the source at the shard upper so it
1043                            // terminates once the replay is done.
1044                            Antichain::from_elem(N_BATCHES),
1045                            Some(move |_, descs, _| (descs, vec![])),
1046                            Arc::new(StringSchema),
1047                            Arc::new(StringSchema),
1048                            FilterResult::keep_all,
1049                            false.then_some(|| unreachable!()),
1050                            async {},
1051                            ErrorHandler::Halt("test"),
1052                        );
1053                        (stream.leave(outer), tokens)
1054                    });
1055                    let probe = ProbeHandle::new();
1056                    // Capture the source's output directly so every progress
1057                    // message is recorded, independent of how many we drain per
1058                    // worker step.
1059                    let receiver = stream.probe_with(&probe).capture();
1060                    (probe, receiver, token)
1061                });
1062
1063                // Step until the source closes its output (until == upper, so it
1064                // drops its capabilities once the replay completes).
1065                let deadline = Instant::now() + std::time::Duration::from_secs(60);
1066                while !probe.with_frontier(|f| f.is_empty()) {
1067                    assert!(Instant::now() < deadline, "timed out hydrating shard");
1068                    worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1069                }
1070
1071                // The largest `Messages` time is the highest timestamp whose
1072                // parts were emitted; it must reach `N_BATCHES - 1` regardless of
1073                // coalescing, because parts always flow per batch and only the
1074                // frontier downgrades are held back.
1075                let mut max_msg_time: Option<u64> = None;
1076                while let Ok(event) = receiver.try_recv() {
1077                    if let CaptureEvent::Messages(time, _) = event {
1078                        max_msg_time = max_msg_time.max(Some(time));
1079                    }
1080                }
1081                max_msg_time
1082            })
1083        }
1084
1085        // Coalescing disabled (default) and enabled with a budget larger than
1086        // the whole replay (a single 0 -> upper jump) must both consume every
1087        // batch and emit parts through the last timestamp. Coalescing changes
1088        // only frontier granularity, not the data emitted or how far we get;
1089        // the round reduction itself is covered by
1090        // `test_frontier_coalesce_decision`, since timely batches progress
1091        // across the dataflow edge in a fast in-mem replay.
1092        assert_eq!(run(0).await, Some(N_BATCHES - 1));
1093        assert_eq!(run(1 << 30).await, Some(N_BATCHES - 1));
1094    }
1095
1096    /// The round-reduction property, tested directly on the forward/coalesce
1097    /// decision so it is independent of timely's progress batching. Simulates
1098    /// replaying `n` unit batches from as-of 0 to upper `n`, each `bytes`
1099    /// encoded bytes, and counts how many progress downgrades get forwarded.
1100    #[mz_ore::test]
1101    fn test_frontier_coalesce_decision() {
1102        fn forwards(coalesce_target: u64, n: u64, bytes_per_batch: u64) -> usize {
1103            let replay_upper = n;
1104            let mut current = 0u64;
1105            let mut coalesced = 0u64;
1106            let mut forwarded = 0usize;
1107            for _ in 0..n {
1108                current += 1;
1109                coalesced += bytes_per_batch;
1110                let caught_up = replay_upper <= current;
1111                // Mirrors the forward/coalesce decision in `shard_source_descs`.
1112                let coalesce = coalesce_target > 0 && !caught_up && coalesced < coalesce_target;
1113                if !coalesce {
1114                    forwarded += 1;
1115                    coalesced = 0;
1116                }
1117            }
1118            forwarded
1119        }
1120
1121        // Disabled: one forward per batch (the per-batch storm).
1122        assert_eq!(forwards(0, 64, 10), 64);
1123        // Budget larger than the whole replay: a single forward at the upper.
1124        assert_eq!(forwards(1 << 30, 64, 10), 1);
1125        // Mid budget: forwards every ~target/bytes batches plus the final
1126        // catch-up forward, so strictly between the two extremes.
1127        let partial = forwards(100, 64, 10);
1128        assert!(
1129            partial > 1 && partial < 64,
1130            "expected partial coalescing, got {partial}"
1131        );
1132    }
1133
1134    /// Verifies that the source fetches and emits actual data: a batch written
1135    /// before the dataflow starts comes out as at least one `FetchedBlob`, and
1136    /// the output frontier reaches the shard's upper.
1137    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1138    #[cfg_attr(miri, ignore)] // too slow
1139    async fn test_shard_source_fetches_data() {
1140        let persist_client = PersistClient::new_for_tests().await;
1141        let shard_id = ShardId::new();
1142
1143        let mut write = persist_client
1144            .open_writer::<String, String, u64, u64>(
1145                shard_id,
1146                Arc::new(StringSchema),
1147                Arc::new(StringSchema),
1148                Diagnostics::for_tests(),
1149            )
1150            .await
1151            .expect("invalid usage");
1152        let data = [
1153            (("k1".to_owned(), "v1".to_owned()), 0u64, 1u64),
1154            (("k2".to_owned(), "v2".to_owned()), 1u64, 1u64),
1155        ];
1156        write.expect_compare_and_append(&data[..], 0, 5).await;
1157
1158        let expected_frontier = 5;
1159        let (blob_count, frontier) = timely::execute::execute_directly(move |worker| {
1160            let as_of = Antichain::from_elem(0);
1161            let until = Antichain::new();
1162
1163            let (capture, probe, token) = worker.dataflow::<u64, _, _>(|outer| {
1164                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1165                    let transformer = move |_, descs, _| (descs, vec![]);
1166                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1167                        outer,
1168                        scope,
1169                        "test_source",
1170                        move || std::future::ready(persist_client.clone()),
1171                        shard_id,
1172                        Some(as_of),
1173                        SnapshotMode::Include,
1174                        until,
1175                        Some(transformer),
1176                        Arc::new(StringSchema),
1177                        Arc::new(StringSchema),
1178                        FilterResult::keep_all,
1179                        false.then_some(|| unreachable!()),
1180                        async {},
1181                        ErrorHandler::Halt("test"),
1182                    );
1183                    (stream.leave(outer), tokens)
1184                });
1185
1186                let probe = ProbeHandle::new();
1187                let stream = stream.probe_with(&probe);
1188                (stream.capture(), probe, token)
1189            });
1190
1191            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1192            while probe.less_than(&expected_frontier) {
1193                assert!(
1194                    Instant::now() < deadline,
1195                    "timed out waiting for output frontier {expected_frontier}"
1196                );
1197                worker.step();
1198            }
1199            drop(token);
1200
1201            let mut blob_count = 0;
1202            while let Ok(event) = capture.try_recv() {
1203                if let CaptureEvent::Messages(_, msgs) = event {
1204                    blob_count += msgs.len();
1205                }
1206            }
1207            let mut frontier = Antichain::new();
1208            probe.with_frontier(|f| frontier.extend(f.iter().cloned()));
1209            (blob_count, frontier)
1210        });
1211
1212        assert!(blob_count >= 1, "expected at least one fetched blob");
1213        assert_eq!(frontier, Antichain::from_elem(expected_frontier));
1214    }
1215
1216    /// Verifies that dropping the source's tokens while it is running does not
1217    /// panic or wedge the worker: capabilities are released so the dataflow can
1218    /// shut down to the empty frontier.
1219    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1220    #[cfg_attr(miri, ignore)] // too slow
1221    async fn test_shard_source_shutdown_mid_stream() {
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 = [(("k1".to_owned(), "v1".to_owned()), 0u64, 1u64)];
1235        write.expect_compare_and_append(&data[..], 0, 5).await;
1236
1237        timely::execute::execute_directly(move |worker| {
1238            let as_of = Antichain::from_elem(0);
1239            // An empty `until` means the source would run forever if not shut
1240            // down by dropping its tokens.
1241            let until = Antichain::new();
1242
1243            let (probe, token) = worker.dataflow::<u64, _, _>(|outer| {
1244                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1245                    let transformer = move |_, descs, _| (descs, vec![]);
1246                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1247                        outer,
1248                        scope,
1249                        "test_source",
1250                        move || std::future::ready(persist_client.clone()),
1251                        shard_id,
1252                        Some(as_of),
1253                        SnapshotMode::Include,
1254                        until,
1255                        Some(transformer),
1256                        Arc::new(StringSchema),
1257                        Arc::new(StringSchema),
1258                        FilterResult::keep_all,
1259                        false.then_some(|| unreachable!()),
1260                        async {},
1261                        ErrorHandler::Halt("test"),
1262                    );
1263                    (stream.leave(outer), tokens)
1264                });
1265
1266                let probe = ProbeHandle::new();
1267                let _stream = stream.probe_with(&probe);
1268                (probe, token)
1269            });
1270
1271            // Step until the source has made progress, so shutdown happens while
1272            // the fetch machinery is live.
1273            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1274            while probe.less_than(&1) {
1275                assert!(Instant::now() < deadline, "timed out waiting for progress");
1276                worker.step();
1277            }
1278
1279            // Shut down and confirm the dataflow drains: with all tokens dropped,
1280            // the operators must release their capabilities and the frontier must
1281            // become empty.
1282            drop(token);
1283            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1284            loop {
1285                assert!(Instant::now() < deadline, "timed out waiting for shutdown");
1286                worker.step();
1287                if probe.with_frontier(|f| f.is_empty()) {
1288                    break;
1289                }
1290            }
1291        });
1292    }
1293
1294    /// Verifies that an unserveable `as_of` (the listing path) reports an error
1295    /// through the `ErrorHandler` and freezes the source: the output frontier
1296    /// stays at the requested `as_of` and the worker does not panic.
1297    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1298    #[cfg_attr(miri, ignore)] // too slow
1299    async fn test_shard_source_error_freeze() {
1300        let persist_client = PersistClient::new_for_tests().await;
1301        let shard_id = ShardId::new();
1302
1303        // Write data so the shard's upper is past the as_of (otherwise
1304        // `snapshot` blocks waiting for the upper instead of erroring on the
1305        // since), then advance the since past the as_of we'll request.
1306        let mut write = persist_client
1307            .open_writer::<String, String, u64, u64>(
1308                shard_id,
1309                Arc::new(StringSchema),
1310                Arc::new(StringSchema),
1311                Diagnostics::for_tests(),
1312            )
1313            .await
1314            .expect("invalid usage");
1315        let data = [(("k1".to_owned(), "v1".to_owned()), 0u64, 1u64)];
1316        write.expect_compare_and_append(&data[..], 0, 5).await;
1317        initialize_shard(&persist_client, shard_id, Antichain::from_elem(3)).await;
1318
1319        let (errored, frontier) = timely::execute::execute_directly(move |worker| {
1320            let as_of = Antichain::from_elem(1);
1321            let until = Antichain::new();
1322
1323            let errored = Rc::new(std::cell::Cell::new(false));
1324            let error_handler = ErrorHandler::signal({
1325                let errored = Rc::clone(&errored);
1326                move |_err| errored.set(true)
1327            });
1328
1329            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
1330                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1331                    let transformer = move |_, descs, _| (descs, vec![]);
1332                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1333                        outer,
1334                        scope,
1335                        "test_source",
1336                        move || std::future::ready(persist_client.clone()),
1337                        shard_id,
1338                        Some(as_of),
1339                        SnapshotMode::Include,
1340                        until,
1341                        Some(transformer),
1342                        Arc::new(StringSchema),
1343                        Arc::new(StringSchema),
1344                        FilterResult::keep_all,
1345                        false.then_some(|| unreachable!()),
1346                        async {},
1347                        error_handler,
1348                    );
1349                    (stream.leave(outer), tokens)
1350                });
1351
1352                let probe = ProbeHandle::new();
1353                let _stream = stream.probe_with(&probe);
1354                (probe, token)
1355            });
1356
1357            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1358            while !errored.get() {
1359                assert!(Instant::now() < deadline, "timed out waiting for error");
1360                worker.step();
1361            }
1362            // Keep stepping; the source must stay frozen at the as_of.
1363            for _ in 0..100 {
1364                worker.step();
1365            }
1366
1367            let mut frontier = Antichain::new();
1368            probe.with_frontier(|f| frontier.extend(f.iter().cloned()));
1369            (errored.get(), frontier)
1370        });
1371
1372        assert!(errored);
1373        assert_eq!(frontier, Antichain::from_elem(1));
1374    }
1375
1376    /// Regression test for the `shard_source_fetch` freeze path: a blob that
1377    /// goes missing while *fetching* (the listing path is covered by
1378    /// `test_shard_source_error_freeze`) must freeze the output frontier at the
1379    /// missing part and report the error, never advancing past data never
1380    /// emitted.
1381    ///
1382    /// We delete the first batch's blob, which is read by the snapshot at
1383    /// `as_of = 0`. Its fetch fails; the later batches (t=1, t=2) fetch fine. We
1384    /// step until the dataflow quiesces (with brief parks so the tokio fetch
1385    /// task finishes), so the later results are produced, then assert the error
1386    /// fired and the frontier stayed at the missing part.
1387    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1388    #[cfg_attr(miri, ignore)] // too slow
1389    async fn test_shard_source_fetch_error_freeze() {
1390        // Force writes to real blobs (inline parts have no blob to delete) and
1391        // disable compaction so the three batches stay distinct and deletable.
1392        let mut cache = PersistClientCache::new_no_metrics();
1393        cache.cfg.compaction_enabled = false;
1394        cache.cfg.set_config(&INLINE_WRITES_SINGLE_MAX_BYTES, 0);
1395        cache.cfg.set_config(&INLINE_WRITES_TOTAL_MAX_BYTES, 0);
1396        let persist_client = cache
1397            .open(PersistLocation::new_in_mem())
1398            .await
1399            .expect("in-mem location is valid");
1400        let shard_id = ShardId::new();
1401        // Clones of a `PersistClient` share the blob `Arc`, so deleting via this
1402        // handle is visible to the reader the source opens.
1403        let blob = Arc::clone(&persist_client.blob);
1404
1405        let mut write = persist_client
1406            .open_writer::<String, String, u64, u64>(
1407                shard_id,
1408                Arc::new(StringSchema),
1409                Arc::new(StringSchema),
1410                Diagnostics::for_tests(),
1411            )
1412            .await
1413            .expect("invalid usage");
1414
1415        // The data-part (non-rollup) blob keys currently present.
1416        async fn batch_keys(blob: &dyn Blob) -> std::collections::BTreeSet<String> {
1417            let mut keys = std::collections::BTreeSet::new();
1418            blob.list_keys_and_metadata("", &mut |meta| {
1419                if let Ok((_, PartialBlobKey::Batch(..))) = BlobKey::parse_ids(meta.key) {
1420                    keys.insert(meta.key.to_owned());
1421                }
1422            })
1423            .await
1424            .expect("list keys");
1425            keys
1426        }
1427
1428        let row = |t: u64| ((format!("k{t}"), format!("v{t}")), t, 1u64);
1429        let before = batch_keys(blob.as_ref()).await;
1430        write.expect_compare_and_append(&[row(0)], 0, 1).await;
1431        let after = batch_keys(blob.as_ref()).await;
1432        write.expect_compare_and_append(&[row(1)], 1, 2).await;
1433        write.expect_compare_and_append(&[row(2)], 2, 3).await;
1434
1435        // Delete exactly the first (t=0) batch's data part(s); the snapshot at
1436        // as_of=0 reads it.
1437        let missing: Vec<_> = after.difference(&before).cloned().collect();
1438        assert!(!missing.is_empty(), "first batch wrote no blob part");
1439        for key in &missing {
1440            blob.delete(key).await.expect("delete");
1441        }
1442
1443        let frontier = timely::execute::execute_directly(move |worker| {
1444            let errored = Rc::new(std::cell::Cell::new(false));
1445            let error_handler = ErrorHandler::signal({
1446                let errored = Rc::clone(&errored);
1447                move |_err| errored.set(true)
1448            });
1449
1450            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
1451                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1452                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1453                        outer,
1454                        scope,
1455                        "test_source",
1456                        move || std::future::ready(persist_client.clone()),
1457                        shard_id,
1458                        Some(Antichain::from_elem(0)),
1459                        SnapshotMode::Include,
1460                        Antichain::new(),
1461                        Some(move |_, descs, _| (descs, vec![])),
1462                        Arc::new(StringSchema),
1463                        Arc::new(StringSchema),
1464                        FilterResult::keep_all,
1465                        false.then_some(|| unreachable!()),
1466                        async {},
1467                        error_handler,
1468                    );
1469                    (stream.leave(outer), tokens)
1470                });
1471                let probe = ProbeHandle::new();
1472                stream.probe_with(&probe);
1473                (probe, token)
1474            });
1475
1476            // Step until the fetch error fires, then step until the dataflow
1477            // quiesces, with brief parks so the tokio fetch task can finish.
1478            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1479            while !errored.get() {
1480                assert!(
1481                    Instant::now() < deadline,
1482                    "timed out waiting for fetch error"
1483                );
1484                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1485            }
1486            let mut last = probe.with_frontier(|f| f.to_owned());
1487            let mut stable = 0;
1488            while stable < 100 {
1489                assert!(Instant::now() < deadline, "timed out waiting for quiesce");
1490                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1491                let now = probe.with_frontier(|f| f.to_owned());
1492                if now == last {
1493                    stable += 1;
1494                } else {
1495                    stable = 0;
1496                    last = now;
1497                }
1498            }
1499            last
1500        });
1501
1502        // Frozen at the missing part (t=0); the bug advanced this past it.
1503        assert_eq!(frontier, Antichain::from_elem(0));
1504    }
1505
1506    /// With `persist_source_fetch_concurrency > 1` the source still fetches
1507    /// every part and reaches the shard upper. The frontier can only reach the
1508    /// upper once every part's fetch has completed, so this proves the
1509    /// concurrent path loses nothing even though results complete out of order.
1510    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1511    #[cfg_attr(miri, ignore)] // too slow
1512    async fn test_shard_source_fetch_concurrent() {
1513        const N_BATCHES: u64 = 16;
1514
1515        let mut cache = PersistClientCache::new_no_metrics();
1516        cache.cfg.compaction_enabled = false;
1517        cache.cfg.set_config(&SOURCE_FETCH_CONCURRENCY, 8);
1518        let persist_client = cache
1519            .open(PersistLocation::new_in_mem())
1520            .await
1521            .expect("in-mem location is valid");
1522        let shard_id = ShardId::new();
1523
1524        let mut write = persist_client
1525            .open_writer::<String, String, u64, u64>(
1526                shard_id,
1527                Arc::new(StringSchema),
1528                Arc::new(StringSchema),
1529                Diagnostics::for_tests(),
1530            )
1531            .await
1532            .expect("invalid usage");
1533        for t in 0..N_BATCHES {
1534            let row = ((format!("k{t}"), format!("v{t}")), t, 1u64);
1535            write.expect_compare_and_append(&[row], t, t + 1).await;
1536        }
1537
1538        let (blob_count, max_time) = timely::execute::execute_directly(move |worker| {
1539            let (capture, probe, token) = worker.dataflow::<u64, _, _>(|outer| {
1540                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1541                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1542                        outer,
1543                        scope,
1544                        "test_source",
1545                        move || std::future::ready(persist_client.clone()),
1546                        shard_id,
1547                        Some(Antichain::from_elem(0)),
1548                        SnapshotMode::Include,
1549                        Antichain::from_elem(N_BATCHES),
1550                        Some(move |_, descs, _| (descs, vec![])),
1551                        Arc::new(StringSchema),
1552                        Arc::new(StringSchema),
1553                        FilterResult::keep_all,
1554                        false.then_some(|| unreachable!()),
1555                        async {},
1556                        ErrorHandler::Halt("test"),
1557                    );
1558                    (stream.leave(outer), tokens)
1559                });
1560                let probe = ProbeHandle::new();
1561                let stream = stream.probe_with(&probe);
1562                (stream.capture(), probe, token)
1563            });
1564
1565            // The source is bounded at the upper, so its frontier empties only
1566            // once every part's fetch has completed; reaching the empty frontier
1567            // proves the concurrent path lost nothing.
1568            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1569            while !probe.with_frontier(|f| f.is_empty()) {
1570                assert!(
1571                    Instant::now() < deadline,
1572                    "timed out waiting for completion"
1573                );
1574                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1575            }
1576            drop(token);
1577
1578            let mut blob_count = 0;
1579            let mut max_time: Option<u64> = None;
1580            while let Ok(event) = capture.try_recv() {
1581                if let CaptureEvent::Messages(time, msgs) = event {
1582                    blob_count += msgs.len();
1583                    max_time = max_time.max(Some(time));
1584                }
1585            }
1586            (blob_count, max_time)
1587        });
1588
1589        assert!(blob_count >= 1, "expected at least one fetched blob");
1590        // Parts were emitted through the last timestamp.
1591        assert_eq!(max_time, Some(N_BATCHES - 1));
1592    }
1593
1594    /// Fetch-path freeze under concurrency: with several fetches in flight, a
1595    /// missing *middle* batch must still freeze the frontier at that batch, even
1596    /// though later batches fetch fine and may complete before the error is
1597    /// observed. This is the out-of-order analogue of
1598    /// `test_shard_source_fetch_error_freeze`; it exercises the time-keyed
1599    /// capability bookkeeping the concurrent path relies on.
1600    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
1601    #[cfg_attr(miri, ignore)] // too slow
1602    async fn test_shard_source_fetch_concurrent_error_freeze() {
1603        const N_BATCHES: u64 = 12;
1604        const MISSING_TS: u64 = 5;
1605
1606        let mut cache = PersistClientCache::new_no_metrics();
1607        cache.cfg.compaction_enabled = false;
1608        cache.cfg.set_config(&INLINE_WRITES_SINGLE_MAX_BYTES, 0);
1609        cache.cfg.set_config(&INLINE_WRITES_TOTAL_MAX_BYTES, 0);
1610        cache.cfg.set_config(&SOURCE_FETCH_CONCURRENCY, 8);
1611        let persist_client = cache
1612            .open(PersistLocation::new_in_mem())
1613            .await
1614            .expect("in-mem location is valid");
1615        let shard_id = ShardId::new();
1616        let blob = Arc::clone(&persist_client.blob);
1617
1618        let mut write = persist_client
1619            .open_writer::<String, String, u64, u64>(
1620                shard_id,
1621                Arc::new(StringSchema),
1622                Arc::new(StringSchema),
1623                Diagnostics::for_tests(),
1624            )
1625            .await
1626            .expect("invalid usage");
1627
1628        async fn batch_keys(blob: &dyn Blob) -> std::collections::BTreeSet<String> {
1629            let mut keys = std::collections::BTreeSet::new();
1630            blob.list_keys_and_metadata("", &mut |meta| {
1631                if let Ok((_, PartialBlobKey::Batch(..))) = BlobKey::parse_ids(meta.key) {
1632                    keys.insert(meta.key.to_owned());
1633                }
1634            })
1635            .await
1636            .expect("list keys");
1637            keys
1638        }
1639
1640        // Write each batch, snapshotting the blob keys around the missing one so
1641        // we can delete exactly its part(s).
1642        let mut missing = Vec::new();
1643        for t in 0..N_BATCHES {
1644            let before = batch_keys(blob.as_ref()).await;
1645            let row = ((format!("k{t}"), format!("v{t}")), t, 1u64);
1646            write.expect_compare_and_append(&[row], t, t + 1).await;
1647            if t == MISSING_TS {
1648                let after = batch_keys(blob.as_ref()).await;
1649                missing = after.difference(&before).cloned().collect();
1650            }
1651        }
1652        assert!(!missing.is_empty(), "missing batch wrote no blob part");
1653        for key in &missing {
1654            blob.delete(key).await.expect("delete");
1655        }
1656
1657        let frontier = timely::execute::execute_directly(move |worker| {
1658            let errored = Rc::new(std::cell::Cell::new(false));
1659            let error_handler = ErrorHandler::signal({
1660                let errored = Rc::clone(&errored);
1661                move |_err| errored.set(true)
1662            });
1663
1664            let (probe, _token) = worker.dataflow::<u64, _, _>(|outer| {
1665                let (stream, token) = outer.scoped::<u64, _, _>("hybrid", |scope| {
1666                    let (stream, tokens) = shard_source::<String, String, u64, u64, _, _, _>(
1667                        outer,
1668                        scope,
1669                        "test_source",
1670                        move || std::future::ready(persist_client.clone()),
1671                        shard_id,
1672                        Some(Antichain::from_elem(0)),
1673                        SnapshotMode::Include,
1674                        Antichain::from_elem(N_BATCHES),
1675                        Some(move |_, descs, _| (descs, vec![])),
1676                        Arc::new(StringSchema),
1677                        Arc::new(StringSchema),
1678                        FilterResult::keep_all,
1679                        false.then_some(|| unreachable!()),
1680                        async {},
1681                        error_handler,
1682                    );
1683                    (stream.leave(outer), tokens)
1684                });
1685                let probe = ProbeHandle::new();
1686                stream.probe_with(&probe);
1687                (probe, token)
1688            });
1689
1690            let deadline = Instant::now() + std::time::Duration::from_secs(60);
1691            while !errored.get() {
1692                assert!(
1693                    Instant::now() < deadline,
1694                    "timed out waiting for fetch error"
1695                );
1696                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1697            }
1698            let mut last = probe.with_frontier(|f| f.to_owned());
1699            let mut stable = 0;
1700            while stable < 100 {
1701                assert!(Instant::now() < deadline, "timed out waiting for quiesce");
1702                worker.step_or_park(Some(std::time::Duration::from_millis(1)));
1703                let now = probe.with_frontier(|f| f.to_owned());
1704                if now == last {
1705                    stable += 1;
1706                } else {
1707                    stable = 0;
1708                    last = now;
1709                }
1710            }
1711            last
1712        });
1713
1714        // Frozen at the missing batch despite later batches fetching fine
1715        // concurrently.
1716        assert_eq!(frontier, Antichain::from_elem(MISSING_TS));
1717    }
1718
1719    async fn initialize_shard(
1720        persist_client: &PersistClient,
1721        shard_id: ShardId,
1722        since: Antichain<u64>,
1723    ) {
1724        let mut read_handle = persist_client
1725            .open_leased_reader::<String, String, u64, u64>(
1726                shard_id,
1727                Arc::new(<std::string::String as mz_persist_types::Codec>::Schema::default()),
1728                Arc::new(<std::string::String as mz_persist_types::Codec>::Schema::default()),
1729                Diagnostics::for_tests(),
1730                true,
1731            )
1732            .await
1733            .expect("invalid usage");
1734
1735        read_handle.downgrade_since(&since).await;
1736    }
1737}