Skip to main content

mz_storage/
upsert_continual_feedback_v2.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//! Implementation of the feedback UPSERT operator.
11//!
12//! # Architecture
13//!
14//! The operator converts a stream of upsert commands `(key, Option<value>)` into
15//! a differential collection of `(key, value)` pairs, using a feedback loop
16//! through persist to maintain the "previous value" state needed for computing
17//! retractions.
18//!
19//! ## Dataflow topology
20//!
21//! ```text
22//!   Source input ──► ┌──────────┐ ──► Output ──► Persist
23//!                    │  Upsert  │
24//!   Persist read ──► └──────────┘
25//!       ▲                                           │
26//!       └───────────── feedback ────────────────────┘
27//! ```
28//!
29//! ## Operator loop (each iteration)
30//!
31//! 1. **Ingest source data.** Read upsert commands from the source input,
32//!    wrap each in an `UpsertDiff` (carrying a columnar order key projected
33//!    from `FromTime` via [`UpsertSourceTime`] for dedup), and push into the
34//!    source-stash batcher. The batcher consolidates entries for the same
35//!    `(key, time)` via the `UpsertDiff` Semigroup, keeping the update with
36//!    the highest order key (latest source offset), through amortized
37//!    geometric merging as data is pushed in, and cold stash state leaves RSS
38//!    through the flavor's spill path (see below). This bounds resident
39//!    memory even during large source snapshots.
40//!
41//! 2. **Read persist frontier.** Check the probe on the persist arrangement
42//!    to learn which times have been committed. When the persist frontier
43//!    reaches the resume upper, rehydration is complete.
44//!
45//! 3. **Extract & drain.** Extract from the batcher just past the persist
46//!    frontier (capped at the input frontier, see `seal_upper`) all
47//!    source-finalized entries as sorted, consolidated chunks. Each entry is
48//!    classified:
49//!    - **Eligible** (at the persist frontier): the persist trace has the
50//!      correct "before" state for this time. Look up the old value in the
51//!      feedback arrangement, emit a retraction if present, and emit the new
52//!      value.
53//!    - **Ineligible** (above the persist frontier, below the extract upper):
54//!      persist hasn't caught up yet. Push back into the batcher for the next
55//!      iteration.
56//!    - **Already persisted** (below the persist frontier): some writer has
57//!      already advanced the shard past this time, so it is dropped. See the
58//!      drain functions for why re-stashing it would strand the data and pin
59//!      the output frontier below the shard upper.
60//!
61//! 4. **Capability management.** Downgrade the output capability to the
62//!    minimum time of any remaining buffered data (in the batcher or pushed
63//!    back as ineligible). Drop the capability entirely when the batcher is
64//!    empty.
65//!
66//! ## Stash flavors
67//!
68//! [`UpsertStashFlavor`], resolved from `enable_upsert_chunked_stash` at
69//! operator construction, selects between two instantiations of the same
70//! loop:
71//!
72//! * **Chunked**: the stash is differential's chunk merge batcher over
73//!   time-major `ColumnChunk`s and the feedback arrangement is a spine of
74//!   chunk batches. Committed chunk bodies spill to the process buffer pool,
75//!   the drain loads sealed chunks one at a time, and prior state comes back
76//!   through bulk probes of the trace's batches.
77//! * **Paged**: the stash is the paged columnar merge batcher and the
78//!   feedback arrangement is a `ValRowSpine`. Cold chains page out of RSS
79//!   through the storage-owned column pager, and prior state comes back
80//!   through a trace cursor.
81//!
82//! Both flavors' spill paths are gated by `enable_upsert_paged_spill`.
83//!
84//! ## Eligibility condition (total order)
85//!
86//! For a total-order timestamp with `input_upper = {i}` and
87//! `persist_upper = {p}`, an entry at time `ts` is eligible when
88//! `ts == p < i` — the source has finalized it and persist is exactly at
89//! that time, so the feedback trace holds the correct prior state. An entry
90//! with `p < ts` is ineligible (persist hasn't caught up), and one with
91//! `ts < p` is already persisted and dropped.
92
93use std::fmt::Debug;
94
95use differential_dataflow::hashable::Hashable;
96use differential_dataflow::operators::arrange::agent::TraceAgent;
97use differential_dataflow::operators::arrange::arrangement::{Arranged, arrange_core};
98use differential_dataflow::trace::TraceReader;
99use differential_dataflow::trace::chunk::ChunkBatcher;
100use differential_dataflow::trace::implementations::merge_batcher::MergeBatcher;
101use differential_dataflow::trace::implementations::spine_fueled::SpineBatch;
102use differential_dataflow::{AsCollection, VecCollection};
103use mz_dyncfg::ConfigSet;
104use mz_ore::cast::CastFrom;
105use mz_repr::{Diff, GlobalId, Row};
106use mz_row_spine::{FundedValRowSpine, ValRowColPagedBuilder};
107use mz_storage_types::dyncfgs::ENABLE_UPSERT_CHUNKED_STASH;
108use mz_storage_types::errors::{DataflowError, EnvelopeError};
109use mz_timely_util::builder_async::{
110    AsyncOutputHandle, Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder,
111    PressOnDropButton, sole_capability,
112};
113use mz_timely_util::columnar::Col2ValPagedBatcher;
114use mz_timely_util::columnar::chunk::{ChunkChunker, StorageSpill};
115use mz_timely_util::columnar::merge_batcher::PagedChunker;
116use mz_timely_util::containers::stack::FueledBuilder;
117use std::convert::Infallible;
118use timely::container::CapacityContainerBuilder;
119use timely::dataflow::StreamVec;
120use timely::dataflow::channels::pact::{Exchange, Pipeline};
121use timely::dataflow::operators::{Capability, CapabilitySet};
122use timely::order::{PartialOrder, TotalOrder};
123use timely::progress::timestamp::Refines;
124use timely::progress::{Antichain, Timestamp};
125
126use crate::healthcheck::HealthStatusUpdate;
127use crate::metrics::upsert::UpsertMetrics;
128use crate::upsert::UpsertKey;
129use crate::upsert::UpsertSourceTime;
130use crate::upsert::UpsertValue;
131
132use self::feedback::{FeedbackChunk, FeedbackSpine, encode_feedback};
133use self::stash::{ChunkedArm, PagedArm, UpsertDiff, UpsertStashArm, UpsertUpdate, seal_upper};
134
135#[cfg(feature = "fuzzing")]
136pub use self::feedback::datum_seq_to_upsert_value;
137pub use self::feedback::upsert_value_to_row;
138
139mod drain;
140mod feedback;
141mod stash;
142#[cfg(test)]
143mod tests;
144
145/// Which stash and feedback-arrangement representation the upsert-v2
146/// operator instantiates. The two flavors run the same operator loop; they
147/// differ in the batcher, the feedback trace, and how the drain reads prior
148/// state. See the module docs for the comparison.
149#[derive(Clone, Copy, Debug)]
150pub enum UpsertStashFlavor {
151    /// Paged columnar merge batcher stash, `ValRowSpine` feedback
152    /// arrangement, cursor-based drain. Spills through the storage-owned
153    /// column pager.
154    Paged,
155    /// Chunk merge batcher stash, chunk-spine feedback arrangement,
156    /// bulk-probe drain. Spills through the process buffer pool.
157    Chunked,
158}
159
160impl UpsertStashFlavor {
161    /// Resolve the flavor from the replica's config set. Call this once per
162    /// source at operator construction time, so a dataflow keeps one flavor
163    /// for its whole life even if the flag flips underneath it.
164    pub fn from_config(config: &ConfigSet) -> Self {
165        if ENABLE_UPSERT_CHUNKED_STASH.get(config) {
166            Self::Chunked
167        } else {
168            Self::Paged
169        }
170    }
171}
172
173/// The operator's data-output handle. A fueled `Vec` builder so the drain can
174/// `give_fueled` each emitted update and yield to timely under large snapshot
175/// drains instead of monopolizing the worker.
176type UpsertOutputHandle<T> =
177    AsyncOutputHandle<T, FueledBuilder<CapacityContainerBuilder<Vec<(UpsertValue, T, Diff)>>>>;
178
179/// Transforms a stream of upserts (key-value updates) into a differential
180/// collection.
181///
182/// Persist feedback is arranged into a differential trace (DD manages the
183/// spine lifecycle). Source input is stashed with a custom `UpsertDiff`
184/// Semigroup that deduplicates by keeping the highest FromTime per (key, time).
185///
186/// Has two inputs:
187///   1. **Source input** — upsert commands from the external source.
188///   2. **Persist input** — feedback of the operator's own output, read back
189///      from persist.  Arranged into a trace for prior-value lookups.
190///
191/// `flavor` selects the stash and feedback-arrangement representation; see
192/// the module docs.
193#[allow(clippy::disallowed_methods)]
194pub fn upsert_inner<'scope, T, FromTime>(
195    flavor: UpsertStashFlavor,
196    input: VecCollection<'scope, T, (UpsertKey, Option<UpsertValue>, FromTime), Diff>,
197    key_indices: Vec<usize>,
198    resume_upper: Antichain<T>,
199    persist_ok: VecCollection<'scope, T, Row, Diff>,
200    persist_err: VecCollection<'scope, T, DataflowError, Diff>,
201    persist_token: Option<Vec<PressOnDropButton>>,
202    upsert_metrics: UpsertMetrics,
203    source_config: crate::source::SourceExportCreationConfig,
204) -> (
205    VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
206    StreamVec<'scope, T, (Option<GlobalId>, HealthStatusUpdate)>,
207    StreamVec<'scope, T, Infallible>,
208    PressOnDropButton,
209)
210where
211    T: Timestamp + TotalOrder + Sync,
212    T: Refines<mz_repr::Timestamp> + differential_dataflow::lattice::Lattice,
213    T: columnation::Columnation,
214    T: mz_timely_util::columnar::chunk::PoolColumnar + Default,
215    for<'a> columnar::Ref<'a, T>: Copy + Ord,
216    FromTime: Debug + timely::ExchangeData + Clone + Ord + Sync,
217    FromTime: UpsertSourceTime,
218{
219    // The feedback keying and encoding are flavor-independent; the
220    // arrangement they feed is not, so each arm names its own arrange types
221    // and hands the arrangement to the shared operator loop.
222    let encoded = encode_feedback(
223        persist_ok,
224        persist_err,
225        key_indices,
226        source_config.source_statistics.clone(),
227    );
228    match flavor {
229        UpsertStashFlavor::Chunked => {
230            // Chains and sealed batches alike are `FeedbackChunk`s whose
231            // bodies spill to the buffer pool, behind the same storage spill
232            // gate as the source stash.
233            let persist_arranged =
234                arrange_core::<
235                    _,
236                    _,
237                    ChunkBatcher<
238                        ChunkChunker<(UpsertKey, Row), T, Diff, StorageSpill>,
239                        FeedbackChunk<T>,
240                    >,
241                    FeedbackSpine<T>,
242                >(encoded, Pipeline, "Persist feedback", MergeBatcher::new);
243            build_upsert_operator::<ChunkedArm, _, _>(
244                input,
245                resume_upper,
246                persist_arranged,
247                persist_token,
248                upsert_metrics,
249                source_config,
250            )
251        }
252        UpsertStashFlavor::Paged => {
253            // The batcher routes its spine input through the storage-owned
254            // pager, paging cold feedback chains out of RSS, while
255            // `ValRowSpine` keeps keys in a columnation arena (`UpsertKey`
256            // is fixed-size `[u8; 32]`) and values as packed `Row` bytes in
257            // a `DatumContainer`.
258            let persist_arranged = arrange_core::<
259                _,
260                _,
261                Col2ValPagedBatcher<
262                    UpsertKey,
263                    Row,
264                    T,
265                    Diff,
266                    PagedChunker<((UpsertKey, Row), T, Diff)>,
267                    ValRowColPagedBuilder<UpsertKey, T, Diff>,
268                >,
269                FundedValRowSpine<UpsertKey, T, Diff>,
270            >(
271                encoded,
272                Pipeline,
273                "Persist feedback",
274                |logger, operator_id| {
275                    // The feedback arrangement must spill under storage's gate, not compute's, so
276                    // capture the storage-owned pager here rather than falling back to the
277                    // process-global one.
278                    let mut batcher = Col2ValPagedBatcher::new(logger, operator_id);
279                    batcher.set_pager(crate::upsert::upsert_stash_pager::pager());
280                    batcher
281                },
282            );
283            build_upsert_operator::<PagedArm, _, _>(
284                input,
285                resume_upper,
286                persist_arranged,
287                persist_token,
288                upsert_metrics,
289                source_config,
290            )
291        }
292    }
293}
294
295/// The flavor-independent upsert-v2 operator loop, generic over an
296/// [`UpsertStashArm`].
297///
298/// Consumes the source input and the arranged persist feedback (constructed
299/// per flavor by [`upsert_inner`]) and drives the ingest / seal / drain /
300/// capability loop described in the module docs, calling through the arm at
301/// the few points where the flavors diverge.
302#[allow(clippy::disallowed_methods)]
303fn build_upsert_operator<'scope, A, T, FromTime>(
304    input: VecCollection<'scope, T, (UpsertKey, Option<UpsertValue>, FromTime), Diff>,
305    resume_upper: Antichain<T>,
306    persist_arranged: Arranged<'scope, TraceAgent<A::Spine>>,
307    persist_token: Option<Vec<PressOnDropButton>>,
308    upsert_metrics: UpsertMetrics,
309    source_config: crate::source::SourceExportCreationConfig,
310) -> (
311    VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
312    StreamVec<'scope, T, (Option<GlobalId>, HealthStatusUpdate)>,
313    StreamVec<'scope, T, Infallible>,
314    PressOnDropButton,
315)
316where
317    A: UpsertStashArm<T, FromTime::Order>,
318    T: Timestamp + TotalOrder + Sync,
319    T: Refines<mz_repr::Timestamp> + differential_dataflow::lattice::Lattice,
320    T: columnation::Columnation,
321    T: mz_timely_util::columnar::chunk::PoolColumnar + Default,
322    for<'a> columnar::Ref<'a, T>: Copy + Ord,
323    FromTime: Debug + timely::ExchangeData + Clone + Ord + Sync,
324    FromTime: UpsertSourceTime,
325{
326    let mut persist_trace = persist_arranged.trace.clone();
327
328    // Probe the persist arrangement's stream for frontier tracking.
329    // This replaces receiving the batch stream as an input — we just
330    // read the probe frontier to know when persist has caught up.
331    use timely::dataflow::operators::Probe;
332    let (persist_probe, _persist_probe_stream) = persist_arranged.stream.probe();
333
334    // Build the async processing operator.
335    let mut builder = AsyncOperatorBuilder::new("Upsert V2".to_string(), input.scope());
336
337    let (output_handle, output) = builder
338        .new_output::<FueledBuilder<CapacityContainerBuilder<Vec<(UpsertValue, T, Diff)>>>>();
339    let (_snapshot_handle, snapshot_stream) =
340        builder.new_output::<CapacityContainerBuilder<Vec<Infallible>>>();
341    let (_health_output, health_stream) = builder
342        .new_output::<CapacityContainerBuilder<Vec<(Option<GlobalId>, HealthStatusUpdate)>>>();
343
344    let mut input = builder.new_input_for(
345        input.inner,
346        Exchange::new(move |((key, _, _), _, _)| UpsertKey::hashed(key)),
347        &output_handle,
348    );
349
350    // We still need the persist stream as an input so the operator wakes
351    // when the persist arrangement produces batches (frontier advances).
352    // We read the actual frontier from the probe though.
353    let mut persist_wakeup = builder.new_disconnected_input(_persist_probe_stream, Pipeline);
354
355    let shutdown_button = builder.build(move |caps| async move {
356        // Hold the persist source tokens for the operator's lifetime so the
357        // feedback shard stays open until shutdown.
358        let _persist_token = persist_token;
359
360        let [output_cap, snapshot_cap, _health_cap]: [_; 3] = caps.try_into().unwrap();
361        drop(output_cap);
362        let mut snapshot_cap = CapabilitySet::from_elem(snapshot_cap);
363
364        let mut hydrating = true;
365
366        // Source stash. The batcher maintains geometrically-sized sorted
367        // chains and consolidates via the UpsertDiff Semigroup as data is
368        // pushed in, bounding memory to O(unique key-time pairs) even during
369        // large initial snapshots. How cold stash state leaves RSS is
370        // flavor-specific; see the arm impls.
371        let mut batcher = A::new_batcher();
372        // The chunker sorts and consolidates raw input into the chunks the
373        // batcher consumes.
374        let mut chunker = A::Chunker::default();
375        // Scratch buffer for accumulating source events before flushing to
376        // the batcher. Drained on each iteration via the chunker.
377        let mut push_buffer: Vec<UpsertUpdate<T, FromTime::Order>> = Vec::new();
378
379        // Capability held at the minimum time of any buffered data. When
380        // Some, the operator may still produce output; when None, the
381        // batcher is empty.
382        let mut stash_cap: Option<Capability<T>> = None;
383        let mut input_upper = Antichain::from_elem(Timestamp::minimum());
384
385        let snapshot_start = std::time::Instant::now();
386        let mut prev_persist_upper = Antichain::from_elem(Timestamp::minimum());
387
388        // Accumulators for rehydration metrics, set as gauges when rehydration completes.
389        let mut rehydration_total: u64 = 0;
390        let mut rehydration_updates: u64 = 0;
391
392        // Main operator loop. Each iteration performs four steps:
393        //   Step 1: Ingest source data into the batcher.
394        //   Step 2: Read the persist frontier and update rehydration state.
395        //   Step 3: Extract from the batcher, drain eligible entries, push back the rest.
396        //   Step 4: Manage the output capability.
397        loop {
398            // Block until woken by source input or a persist frontier advance.
399            tokio::select! {
400                _ = input.ready() => {}
401                _ = persist_wakeup.ready() => {
402                    while let Some(event) = persist_wakeup.next_sync() {
403                        if let AsyncEvent::Data(_, batches) = event {
404                            for batch in batches {
405                                mz_timely_util::columnar::chunk::metrics::record_batch(
406                                    batch.inner.as_ref().map_or(0, SpineBatch::len),
407                                );
408                            }
409                        }
410                    }
411                }
412            }
413
414            // Step 1: Ingest source data.
415            // Read all available source events, wrap each value in an
416            // UpsertDiff (carrying FromTime for dedup), and buffer them.
417            // Events before the resume_upper are dropped (already persisted).
418            while let Some(event) = input.next_sync() {
419                match event {
420                    AsyncEvent::Data(cap, data) => {
421                        let cap = sole_capability(&cap).clone();
422                        let mut pushed_any = false;
423                        for ((key, value, from_time), ts, diff) in data {
424                            assert!(diff.is_positive(), "invalid upsert input");
425                            if PartialOrder::less_equal(&input_upper, &resume_upper)
426                                && !resume_upper.less_equal(&ts)
427                            {
428                                continue;
429                            }
430                            let value = value.as_ref().map(upsert_value_to_row);
431                            let from_time = from_time.upsert_order();
432                            push_buffer.push((key, ts, UpsertDiff { from_time, value }));
433                            pushed_any = true;
434                        }
435                        // Track the minimum capability across all buffered data
436                        // so we can emit output at the correct times.
437                        if pushed_any {
438                            stash_cap = Some(match stash_cap {
439                                Some(prev) if cap.time() < prev.time() => cap,
440                                Some(prev) => prev,
441                                None => cap,
442                            });
443                        }
444                    }
445                    AsyncEvent::Progress(upper) => {
446                        if PartialOrder::less_than(&upper, &resume_upper) {
447                            continue;
448                        }
449                        input_upper = upper;
450                    }
451                }
452            }
453
454            // Flush buffered events through the chunker into the batcher. This
455            // triggers the chunker + geometric chain merging, which consolidates
456            // entries for the same (key, time) via the UpsertDiff Semigroup.
457            A::flush(&mut push_buffer, &mut chunker, &mut batcher);
458
459            // Step 2: Read persist frontier.
460            // The persist probe tells us which output times have been
461            // committed back through the feedback loop. This determines:
462            //   - Whether rehydration is complete (persist >= resume_upper).
463            //   - Which source entries are eligible for processing (their
464            //     time must equal persist_upper so the feedback trace holds
465            //     the correct prior state).
466            //   - How far to compact the persist trace.
467            let persist_upper = persist_probe.with_frontier(|f| f.to_owned());
468
469            if persist_upper != prev_persist_upper {
470                let last_rehydration_chunk =
471                    hydrating && PartialOrder::less_equal(&resume_upper, &persist_upper);
472
473                if last_rehydration_chunk {
474                    hydrating = false;
475                    upsert_metrics
476                        .rehydration_latency
477                        .set(snapshot_start.elapsed().as_secs_f64());
478                    upsert_metrics.rehydration_total.set(rehydration_total);
479                    upsert_metrics.rehydration_updates.set(rehydration_updates);
480                    tracing::info!(
481                        worker_id = %source_config.worker_id,
482                        source_id = %source_config.id,
483                        "upsert finished rehydration",
484                    );
485                    snapshot_cap.downgrade(&[]);
486                }
487
488                let _ = snapshot_cap.try_downgrade(persist_upper.iter());
489
490                // Compact the trace so the spine can merge old batches.
491                persist_trace.set_logical_compaction(persist_upper.borrow());
492                persist_trace.set_physical_compaction(persist_upper.borrow());
493
494                prev_persist_upper = persist_upper.clone();
495            }
496
497            // Step 3: Extract & drain.
498            // Extract from the batcher at `seal_upper`, just past
499            // persist_upper and at most input_upper, all source-finalized
500            // entries as sorted, consolidated chunks. The extract merges all
501            // internal chains and splits by time: entries below the extract
502            // upper are extracted, the rest stay in the batcher.
503            //
504            // Extracted entries are partitioned into:
505            //   - Eligible (ts == persist_upper): processed now via a
506            //     prior-value lookup in the persist trace.
507            //   - Ineligible (persist_upper < ts < extract upper): persist
508            //     hasn't caught up yet, so pushed back into the batcher.
509            //
510            // We skip the extract entirely unless an eligible entry is at all
511            // possible. `extract` merges all chains regardless of how much it
512            // extracts, so calling it when nothing can be processed makes
513            // the operator quadratic in the number of wakeups (a real
514            // pathology during upstream snapshots and during rehydration
515            // when the source races ahead of persist).
516            //
517            // For an entry at `ts` to be eligible we need
518            // `ts == persist_upper && ts < input_upper`. The necessary
519            // preconditions, expressible without scanning the batcher:
520            //   1. `cap.time() <= persist_upper`. Since `cap.time()` is
521            //      maintained as a lower bound on `min(ts in batcher)`, if
522            //      `cap.time() > persist_upper` then every buffered ts is
523            //      strictly above persist_upper and none can equal it.
524            //   2. `persist_upper < input_upper`. Otherwise no `ts` that
525            //      satisfies `ts == persist_upper` can also satisfy
526            //      `ts < input_upper`.
527            //
528            // This naturally covers both the post-hydration source-snapshot
529            // case (cap == persist == input → condition 2 fails) and the
530            // rehydration-with-source-ahead case (cap > persist → condition
531            // 1 fails). It also no-ops correctly when persist has shut down
532            // (empty persist_upper makes condition 2 vacuously false).
533            if let Some(cap) = stash_cap.as_mut()
534                && !persist_upper.less_than(cap.time())
535                && PartialOrder::less_than(&persist_upper, &input_upper)
536            {
537                // Step 1 already consolidated `push_buffer` through the chunker
538                // (which readies a complete chunk per `push_into`) and pushed
539                // those chunks into the batcher, so we can extract directly.
540                // `remaining_frontier` bounds the data left behind (ts >= the
541                // extract upper).
542                let (sealed, remaining_frontier) = A::extract(
543                    &mut batcher,
544                    seal_upper(&persist_upper, &input_upper).borrow(),
545                );
546
547                let mut ineligible = Vec::new();
548                // The drain emits eligible output directly through
549                // `output_handle` (fueled), so there is no intermediate
550                // output buffer to drain afterward.
551                let drain_stats = A::drain(
552                    sealed,
553                    &mut ineligible,
554                    &output_handle,
555                    &*cap,
556                    &persist_upper,
557                    &mut persist_trace,
558                    source_config.worker_id,
559                    source_config.id,
560                )
561                .await;
562
563                upsert_metrics.multi_get_size.inc_by(drain_stats.eligible);
564                upsert_metrics
565                    .multi_get_result_count
566                    .inc_by(drain_stats.result_count);
567                upsert_metrics
568                    .multi_put_size
569                    .inc_by(drain_stats.output_count);
570                upsert_metrics.upsert_inserts.inc_by(drain_stats.inserts);
571                upsert_metrics.upsert_updates.inc_by(drain_stats.updates);
572                upsert_metrics.upsert_deletes.inc_by(drain_stats.deletes);
573                upsert_metrics
574                    .restashed
575                    .inc_by(u64::cast_from(ineligible.len()));
576
577                if hydrating {
578                    rehydration_total += drain_stats.inserts;
579                    rehydration_updates += drain_stats.eligible;
580                }
581
582                // Step 4: Capability management.
583                // Downgrade the output capability to the minimum time of any
584                // remaining data: either entries still in the batcher (at or
585                // above the seal upper) or ineligible entries being pushed back.
586                let min_ineligible_ts = ineligible.iter().map(|(_, ts, _)| ts).min().cloned();
587                A::flush(&mut ineligible, &mut chunker, &mut batcher);
588
589                // `Option::min` alone would be wrong here, `None` sorts low.
590                // Chain the candidates and take the min over present ones.
591                let min_ts = remaining_frontier
592                    .elements()
593                    .first()
594                    .into_iter()
595                    .chain(min_ineligible_ts.as_ref())
596                    .min();
597                match min_ts {
598                    Some(min_ts) => cap.downgrade(min_ts),
599                    // Batcher is completely empty. Drop the capability so
600                    // downstream operators can make progress.
601                    None => stash_cap = None,
602                }
603            }
604
605            if input_upper.is_empty() {
606                break;
607            }
608        }
609    });
610
611    (
612        output
613            .as_collection()
614            .map(|result: UpsertValue| match result {
615                Ok(ok) => Ok(ok),
616                Err(err) => Err(DataflowError::from(EnvelopeError::Upsert(*err))),
617            }),
618        health_stream,
619        snapshot_stream,
620        shutdown_button.press_on_drop(),
621    )
622}