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. **Seal & drain.** Call `batcher.seal(input_upper)` to extract all
46//!    source-finalized entries as sorted, consolidated chunks. Each entry is
47//!    classified:
48//!    - **Eligible** (at the persist frontier): the persist trace has the
49//!      correct "before" state for this time. Look up the old value in the
50//!      feedback arrangement, emit a retraction if present, and emit the new
51//!      value.
52//!    - **Ineligible** (between persist and input frontiers): persist hasn't
53//!      caught up yet. Push back into the batcher for the next iteration.
54//!    - **Already persisted** (below the persist frontier): some writer has
55//!      already advanced the shard past this time, so it is dropped. See the
56//!      drain functions for why re-stashing it would strand the data and pin
57//!      the output frontier below the shard upper.
58//!
59//! 4. **Capability management.** Downgrade the output capability to the
60//!    minimum time of any remaining buffered data (in the batcher or pushed
61//!    back as ineligible). Drop the capability entirely when the batcher is
62//!    empty.
63//!
64//! ## Stash flavors
65//!
66//! [`UpsertStashFlavor`], resolved from `enable_upsert_chunked_stash` at
67//! operator construction, selects between two instantiations of the same
68//! loop:
69//!
70//! * **Chunked**: the stash is differential's chunk merge batcher over
71//!   `ColumnChunk`s and the feedback arrangement is a spine of chunk batches.
72//!   Committed chunk bodies spill to the process buffer pool, the drain
73//!   loads sealed chunks one at a time, and prior state comes back through
74//!   bulk probes of the trace's batches.
75//! * **Paged**: the stash is the paged columnar merge batcher and the
76//!   feedback arrangement is a `ValRowSpine`. Cold chains page out of RSS
77//!   through the storage-owned column pager, and prior state comes back
78//!   through a trace cursor.
79//!
80//! Both flavors' spill paths are gated by `enable_upsert_paged_spill`.
81//!
82//! ## Eligibility condition (total order)
83//!
84//! For a total-order timestamp with `input_upper = {i}` and
85//! `persist_upper = {p}`, an entry at time `ts` is eligible when
86//! `ts == p < i` — the source has finalized it and persist is exactly at
87//! that time, so the feedback trace holds the correct prior state. An entry
88//! with `p < ts` is ineligible (persist hasn't caught up), and one with
89//! `ts < p` is already persisted and dropped.
90
91use std::fmt::Debug;
92
93use differential_dataflow::difference::{IsZero, Semigroup};
94use differential_dataflow::hashable::Hashable;
95use differential_dataflow::lattice::Lattice;
96use differential_dataflow::logging::Logger;
97use differential_dataflow::operators::arrange::agent::TraceAgent;
98use differential_dataflow::operators::arrange::arrangement::{Arranged, arrange_core};
99use differential_dataflow::trace::chunk::{ChunkBatcher, ChunkBuilder, ChunkSpine};
100use differential_dataflow::trace::{Batcher, Cursor, Description, TraceReader};
101use differential_dataflow::{AsCollection, VecCollection};
102use mz_dyncfg::ConfigSet;
103use mz_repr::{Datum, Diff, GlobalId, Row};
104// Only the fuzzing-gated `datum_seq_to_upsert_value` takes a `DatumSeq`.
105#[cfg(feature = "fuzzing")]
106use mz_row_spine::DatumSeq;
107use mz_row_spine::{ValRowColPagedBuilder, ValRowSpine};
108use mz_storage_types::dyncfgs::ENABLE_UPSERT_CHUNKED_STASH;
109use mz_storage_types::errors::{DataflowError, EnvelopeError, UpsertError};
110use mz_timely_util::builder_async::{
111    AsyncOutputHandle, Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder,
112    PressOnDropButton,
113};
114use mz_timely_util::columnar::batcher::ColumnChunker;
115use mz_timely_util::columnar::builder::ColumnBuilder;
116use mz_timely_util::columnar::chunk::{ChunkChunker, ColumnChunk};
117use mz_timely_util::columnar::merge_batcher::ColumnMergeBatcher;
118use mz_timely_util::columnar::unload::UnloadBatch;
119use mz_timely_util::columnar::{Col2ValPagedBatcher, Column};
120use mz_timely_util::containers::stack::FueledBuilder;
121use std::convert::Infallible;
122use timely::container::{CapacityContainerBuilder, PushInto};
123use timely::dataflow::channels::pact::{Exchange, Pipeline};
124use timely::dataflow::operators::generic::Operator;
125use timely::dataflow::operators::{Capability, CapabilitySet, Exchange as _};
126use timely::dataflow::{Stream, StreamVec};
127use timely::order::{PartialOrder, TotalOrder};
128use timely::progress::frontier::AntichainRef;
129use timely::progress::timestamp::Refines;
130use timely::progress::{Antichain, Timestamp};
131
132use crate::healthcheck::HealthStatusUpdate;
133use crate::metrics::upsert::UpsertMetrics;
134use crate::statistics::SourceStatistics;
135use crate::upsert::UpsertKey;
136use crate::upsert::UpsertSourceTime;
137use crate::upsert::UpsertValue;
138
139/// Which stash and feedback-arrangement representation the upsert-v2
140/// operator instantiates. The two flavors run the same operator loop; they
141/// differ in the batcher, the feedback trace, and how the drain reads prior
142/// state. See the module docs for the comparison.
143#[derive(Clone, Copy, Debug)]
144pub enum UpsertStashFlavor {
145    /// Paged columnar merge batcher stash, `ValRowSpine` feedback
146    /// arrangement, cursor-based drain. Spills through the storage-owned
147    /// column pager.
148    Paged,
149    /// Chunk merge batcher stash, chunk-spine feedback arrangement,
150    /// bulk-probe drain. Spills through the process buffer pool.
151    Chunked,
152}
153
154impl UpsertStashFlavor {
155    /// Resolve the flavor from the replica's config set. Call this once per
156    /// source at operator construction time, so a dataflow keeps one flavor
157    /// for its whole life even if the flag flips underneath it.
158    pub fn from_config(config: &ConfigSet) -> Self {
159        if ENABLE_UPSERT_CHUNKED_STASH.get(config) {
160            Self::Chunked
161        } else {
162            Self::Paged
163        }
164    }
165}
166
167/// The paged flavor's persist-feedback batcher, wrapping
168/// [`Col2ValPagedBatcher`] only to capture the storage upsert-stash pager at
169/// construction.
170///
171/// `arrange_core` builds its batcher via [`Batcher::new`], which has no pager
172/// hook, so a plain `Col2ValPagedBatcher` falls back to the process-global
173/// (compute) pager, meaning the feedback arrangement's spill would be gated
174/// by compute's `enable_column_paged_batcher_spill` rather than storage's
175/// `enable_upsert_paged_spill`. Injecting `upsert_stash_pager::pager()` in
176/// `new` puts the feedback arrangement under the same flag as the source
177/// stash. Every other method delegates to the inner batcher unchanged.
178struct UpsertFeedbackBatcher<T: columnar::Columnar>(Col2ValPagedBatcher<UpsertKey, Row, T, Diff>);
179
180impl<T> Batcher for UpsertFeedbackBatcher<T>
181where
182    T: Timestamp + columnar::Columnar + Default + PartialOrder,
183    for<'a> columnar::Ref<'a, T>: Copy + Ord,
184{
185    type Output = Column<((UpsertKey, Row), T, Diff)>;
186    type Time = T;
187
188    fn new(logger: Option<Logger>, operator_id: usize) -> Self {
189        let mut batcher =
190            <Col2ValPagedBatcher<UpsertKey, Row, T, Diff> as Batcher>::new(logger, operator_id);
191        batcher.set_pager(crate::upsert::upsert_stash_pager::pager());
192        Self(batcher)
193    }
194
195    fn seal(&mut self, upper: Antichain<T>) -> (Vec<Self::Output>, Description<T>) {
196        self.0.seal(upper)
197    }
198
199    fn frontier(&mut self) -> AntichainRef<'_, T> {
200        self.0.frontier()
201    }
202}
203
204impl<T> PushInto<Column<((UpsertKey, Row), T, Diff)>> for UpsertFeedbackBatcher<T>
205where
206    T: Timestamp + columnar::Columnar + Default + PartialOrder,
207    for<'a> columnar::Ref<'a, T>: Copy + Ord,
208{
209    fn push_into(&mut self, chunk: Column<((UpsertKey, Row), T, Diff)>) {
210        self.0.push_into(chunk)
211    }
212}
213
214/// One persist-feedback update: a key, its current value row, the time, and
215/// an additive count.
216type FeedbackUpdate<T> = ((UpsertKey, Row), T, Diff);
217
218/// One feedback-arrangement chunk: a sorted, consolidated run of updates,
219/// resident or spilled to the buffer pool. Both batcher chains and sealed
220/// spine batches are sequences of these, so the arrangement's state pages out
221/// of RSS under the pool's budget, and the drain reads it back through the
222/// bulk [`UnloadChunk`](mz_timely_util::columnar::unload::UnloadChunk)
223/// surface: copy-out probes, no cursor borrows.
224type FeedbackChunk<T> = ColumnChunk<(UpsertKey, Row), T, Diff>;
225
226/// The feedback arrangement's trace: a spine of `Rc`-shared chunk batches.
227type FeedbackSpine<T> = ChunkSpine<FeedbackChunk<T>>;
228
229// The source stash carries the upsert payload in a custom diff type so the
230// merge batcher consolidates by (key, time), keeping the update with the
231// highest `FromTime` (latest source offset) per group. The diff is `Columnar`
232// so the paged merge batcher can store it in a `Column` and page it out of RSS.
233//
234// The value is a tag-encoded `Row` (see `upsert_value_to_row`) rather than an
235// `UpsertValue`: folding both the `Ok` and `Err` arms into one `Row` lets the
236// value share a single columnar byte container, and `Row` already implements
237// `Columnar`. `None` is a deletion tombstone.
238
239// Derive ordering on the generated `UpsertDiffReference` too: the paged merge
240// batcher requires `Ref: Ord` to sort the `(key, time, diff)` columns it
241// consolidates. The derived order (by `from_time`, then `value`) is fine —
242// "max FromTime wins" can tie only between equal `from_time`s, and a source
243// never emits two distinct values for the same `(key, time, from_time)`, so
244// the consolidated result doesn't depend on the fold order of equal
245// `(key, time)` runs.
246#[derive(Clone, Debug, Default, columnar::Columnar)]
247#[columnar(derive(PartialEq, Eq, PartialOrd, Ord))]
248struct UpsertDiff<O> {
249    from_time: O,
250    value: Option<Row>,
251}
252
253impl<O> IsZero for UpsertDiff<O> {
254    fn is_zero(&self) -> bool {
255        false
256    }
257}
258
259impl<O: Ord + Clone> Semigroup for UpsertDiff<O> {
260    fn plus_equals(&mut self, rhs: &Self) {
261        if rhs.from_time > self.from_time {
262            *self = rhs.clone();
263        }
264    }
265}
266
267// Accumulate a borrowed columnar reference: the paged merge batcher consolidates
268// `Column`-resident diffs through this path on every fold of an equal
269// `(key, time)` run. Materialize only the order key to decide the "max FromTime
270// wins" comparison — copying the value `Row` out of the column solely when `rhs`
271// wins. Losing folds (the common case for a repeatedly-updated key) then pay no
272// `Row` copy at all.
273impl<'a, O> Semigroup<columnar::Ref<'a, UpsertDiff<O>>> for UpsertDiff<O>
274where
275    O: columnar::Columnar + Ord + Clone,
276{
277    fn plus_equals(&mut self, rhs: &columnar::Ref<'a, UpsertDiff<O>>) {
278        let rhs_from_time = <O as columnar::Columnar>::into_owned(rhs.from_time);
279        if rhs_from_time > self.from_time {
280            self.from_time = rhs_from_time;
281            self.value = <Option<Row> as columnar::Columnar>::into_owned(rhs.value);
282        }
283    }
284}
285
286/// One source-stash update: a key, its dataflow time, and the payload diff.
287/// `O` is the columnar order key projected from the source `FromTime` (see
288/// [`UpsertSourceTime`]).
289type UpsertUpdate<T, O> = (UpsertKey, T, UpsertDiff<O>);
290
291/// One stash chunk: a sorted, consolidated run of updates, resident or
292/// spilled to the buffer pool.
293type UpsertChunk<T, O> = ColumnChunk<UpsertKey, T, UpsertDiff<O>>;
294
295/// The chunked flavor's stash: differential's chunk merge batcher over
296/// `ColumnChunk`s. Data is pushed in unsorted. The batcher maintains
297/// geometrically-sized sorted chains and consolidates via the UpsertDiff
298/// Semigroup automatically. Committed chunks spill their bodies to the
299/// process buffer pool (see `mz_timely_util::columnar::chunk`), so the
300/// not-yet-eligible backlog (the snapshot / persist-lag window) pages out of
301/// RSS instead of growing it.
302type UpsertChunkBatcher<T, O> = ChunkBatcher<UpsertChunk<T, O>>;
303
304/// The paged flavor's stash: the paged columnar merge batcher, consolidating
305/// like [`UpsertChunkBatcher`] but storing each chain entry as a `Column`
306/// routed through the storage-owned pager, which pages cold chains out of
307/// RSS.
308type UpsertPagedBatcher<T, O> = ColumnMergeBatcher<UpsertKey, T, UpsertDiff<O>>;
309
310/// The chunker that sorts and consolidates raw input into the `Column` chunks
311/// both stash batchers consume.
312type UpsertChunker<T, O> = ColumnChunker<UpsertUpdate<T, O>>;
313
314/// The operator's data-output handle. A fueled `Vec` builder so the drain can
315/// `give_fueled` each emitted update and yield to timely under large snapshot
316/// drains instead of monopolizing the worker.
317type UpsertOutputHandle<T> =
318    AsyncOutputHandle<T, FueledBuilder<CapacityContainerBuilder<Vec<(UpsertValue, T, Diff)>>>>;
319
320// The persist-feedback arrangement stores `(UpsertKey, Row)` pairs in columnar
321// chunk batches ([`FeedbackSpine`]). `UpsertValue` is
322// `Result<Row, Box<UpsertError>>`, so we fold both arms into a single `Row`
323// with a leading tag column so they share the value column.
324
325/// Encode an [`UpsertValue`] as a `Row` with a leading tag column so both `Ok`
326/// and `Err` payloads round-trip through `Row` byte storage.
327///
328/// Used on the render path. `pub` only so [`crate::fuzz_exports`] can re-export
329/// it under the `fuzzing` feature for the storage fuzz crate. The enclosing
330/// module is crate-private, so it is not otherwise reachable. Not a stable
331/// public API.
332#[doc(hidden)]
333pub fn upsert_value_to_row(value: &UpsertValue) -> Row {
334    let mut row = Row::default();
335    let mut packer = row.packer();
336    match value {
337        Ok(ok) => {
338            packer.push(Datum::UInt8(0));
339            packer.extend(ok.iter());
340        }
341        Err(err) => {
342            packer.push(Datum::UInt8(1));
343            let bytes =
344                bincode::serialize(err.as_ref()).expect("UpsertError is serializable via bincode");
345            packer.push(Datum::Bytes(&bytes));
346        }
347    }
348    row
349}
350
351/// Heap-size estimate for an emitted [`UpsertValue`], used to drive
352/// `give_fueled` yielding on the output edge.
353fn upsert_value_byte_len(value: &UpsertValue) -> usize {
354    match value {
355        Ok(row) => row.byte_len(),
356        Err(err) => std::mem::size_of_val(err.as_ref()),
357    }
358}
359
360/// Decode an [`UpsertValue`] produced by [`upsert_value_to_row`] back from the
361/// `DatumSeq` view returned by a `ValRowSpine` cursor.
362///
363/// Exists only for the storage fuzz crate, so it is gated behind the `fuzzing`
364/// feature. Not a stable public API.
365#[cfg(feature = "fuzzing")]
366#[doc(hidden)]
367pub fn datum_seq_to_upsert_value(seq: DatumSeq<'_>) -> UpsertValue {
368    decode_upsert_value(seq)
369}
370
371/// Decode an [`UpsertValue`] produced by [`upsert_value_to_row`] from any
372/// datum iterator, whether over a columnar `Row` reference or an owned `Row`.
373fn decode_upsert_value<'a>(mut iter: impl Iterator<Item = Datum<'a>>) -> UpsertValue {
374    let tag = match iter.next() {
375        Some(Datum::UInt8(tag)) => tag,
376        other => panic!("upsert value missing UInt8 tag, got {:?}", other),
377    };
378    match tag {
379        0 => {
380            let mut row = Row::default();
381            row.packer().extend(iter);
382            Ok(row)
383        }
384        1 => {
385            let bytes = match iter.next() {
386                Some(Datum::Bytes(b)) => b,
387                other => panic!("upsert error tag missing Bytes payload, got {:?}", other),
388            };
389            let err: UpsertError =
390                bincode::deserialize(bytes).expect("UpsertError bincode round-trip");
391            Err(Box::new(err))
392        }
393        tag => panic!("unknown upsert value tag {tag}"),
394    }
395}
396
397/// Transforms a stream of upserts (key-value updates) into a differential
398/// collection.
399///
400/// Persist feedback is arranged into a differential trace (DD manages the
401/// spine lifecycle). Source input is stashed with a custom `UpsertDiff`
402/// Semigroup that deduplicates by keeping the highest FromTime per (key, time).
403///
404/// Has two inputs:
405///   1. **Source input** — upsert commands from the external source.
406///   2. **Persist input** — feedback of the operator's own output, read back
407///      from persist.  Arranged into a trace for prior-value lookups.
408///
409/// `flavor` selects the stash and feedback-arrangement representation; see
410/// the module docs.
411#[allow(clippy::disallowed_methods)]
412pub fn upsert_inner<'scope, T, FromTime>(
413    flavor: UpsertStashFlavor,
414    input: VecCollection<'scope, T, (UpsertKey, Option<UpsertValue>, FromTime), Diff>,
415    key_indices: Vec<usize>,
416    resume_upper: Antichain<T>,
417    persist_input: VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
418    persist_token: Option<Vec<PressOnDropButton>>,
419    upsert_metrics: UpsertMetrics,
420    source_config: crate::source::SourceExportCreationConfig,
421) -> (
422    VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
423    StreamVec<'scope, T, (Option<GlobalId>, HealthStatusUpdate)>,
424    StreamVec<'scope, T, Infallible>,
425    PressOnDropButton,
426)
427where
428    T: Timestamp + TotalOrder + Sync,
429    T: Refines<mz_repr::Timestamp> + differential_dataflow::lattice::Lattice,
430    T: columnation::Columnation,
431    T: columnar::Columnar + Default,
432    for<'a> columnar::Ref<'a, T>: Copy + Ord,
433    FromTime: Debug + timely::ExchangeData + Clone + Ord + Sync,
434    FromTime: UpsertSourceTime,
435{
436    // The feedback keying and encoding are flavor-independent; the
437    // arrangement they feed is not, so each arm names its own arrange types
438    // and hands the arrangement to the shared operator loop.
439    let encoded = encode_feedback(
440        persist_input,
441        key_indices,
442        source_config.source_statistics.clone(),
443    );
444    match flavor {
445        UpsertStashFlavor::Chunked => {
446            // Chains and sealed batches alike are `FeedbackChunk`s whose
447            // bodies spill to the buffer pool, behind the same process spill
448            // gate as the source stash.
449            let persist_arranged = arrange_core::<
450                _,
451                _,
452                ChunkChunker<(UpsertKey, Row), T, Diff>,
453                ChunkBatcher<FeedbackChunk<T>>,
454                ChunkBuilder<FeedbackChunk<T>>,
455                FeedbackSpine<T>,
456            >(encoded, Pipeline, "Persist feedback");
457            build_upsert_operator::<ChunkedArm, _, _>(
458                input,
459                resume_upper,
460                persist_arranged,
461                persist_token,
462                upsert_metrics,
463                source_config,
464            )
465        }
466        UpsertStashFlavor::Paged => {
467            // The batcher routes its spine input through the storage-owned
468            // pager, paging cold feedback chains out of RSS, while
469            // `ValRowSpine` keeps keys in a columnation arena (`UpsertKey`
470            // is fixed-size `[u8; 32]`) and values as packed `Row` bytes in
471            // a `DatumContainer`.
472            let persist_arranged = arrange_core::<
473                _,
474                _,
475                ColumnChunker<((UpsertKey, Row), T, Diff)>,
476                UpsertFeedbackBatcher<T>,
477                ValRowColPagedBuilder<UpsertKey, T, Diff>,
478                ValRowSpine<UpsertKey, T, Diff>,
479            >(encoded, Pipeline, "Persist feedback");
480            build_upsert_operator::<PagedArm, _, _>(
481                input,
482                resume_upper,
483                persist_arranged,
484                persist_token,
485                upsert_metrics,
486                source_config,
487            )
488        }
489    }
490}
491
492/// Key the persist feedback by [`UpsertKey`], record source statistics, and
493/// encode `(UpsertKey, UpsertValue)` as `(UpsertKey, Row)` `Column` chunks,
494/// the input both flavors' feedback arrangements consume. Built with
495/// `Pipeline` downstream of an `UpsertKey::hashed` exchange, so the
496/// arrangement keeps that locality.
497fn encode_feedback<'scope, T>(
498    persist_input: VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
499    key_indices: Vec<usize>,
500    source_statistics: SourceStatistics,
501) -> Stream<'scope, T, Column<((UpsertKey, Row), T, Diff)>>
502where
503    T: Timestamp + TotalOrder + Sync,
504    T: Refines<mz_repr::Timestamp> + differential_dataflow::lattice::Lattice,
505    T: columnation::Columnation,
506    T: columnar::Columnar + Default,
507    for<'a> columnar::Ref<'a, T>: Copy + Ord,
508{
509    // Extract (UpsertKey, UpsertValue) from the persist feedback collection.
510    let persist_keyed = persist_input.flat_map(move |result| {
511        let value = match result {
512            Ok(ok) => Ok(ok),
513            Err(DataflowError::EnvelopeError(err)) => match *err {
514                EnvelopeError::Upsert(err) => Err(Box::new(err)),
515                EnvelopeError::Flat(_) => return None,
516            },
517            Err(_) => return None,
518        };
519        let value_ref = match value {
520            Ok(ref row) => Ok(row),
521            Err(ref err) => Err(&**err),
522        };
523        Some((UpsertKey::from_value(value_ref, &key_indices), value))
524    });
525    let persist_keyed = persist_keyed
526        .inner
527        // The arrangement already implicitly exchanges by key, so this is redundant, but we want to
528        // do it earlier so that we can inspect the stream properly for source statistics.
529        .exchange(move |((key, _), _, _)| UpsertKey::hashed(key))
530        .as_collection()
531        .inspect(move |((_, row), _, diff)| {
532            source_statistics.update_records_indexed_by(diff.into_inner());
533            source_statistics.update_bytes_indexed_by(
534                row.as_ref().map_or(0, |r| r.byte_len().try_into().unwrap()) * diff.into_inner(),
535            );
536        });
537    persist_keyed
538        .inner
539        .unary::<ColumnBuilder<((UpsertKey, Row), T, Diff)>, _, _, _>(
540            Pipeline,
541            "Persist feedback encode",
542            |_, _| {
543                move |input, output| {
544                    input.for_each(|time, data| {
545                        let mut session = output.session_with_builder(&time);
546                        for ((key, value), ts, diff) in data.drain(..) {
547                            let row = upsert_value_to_row(&value);
548                            session.give(((&key, &row), &ts, &diff));
549                        }
550                    });
551                }
552            },
553        )
554}
555
556/// The flavor-independent upsert-v2 operator loop, generic over an
557/// [`UpsertStashArm`].
558///
559/// Consumes the source input and the arranged persist feedback (constructed
560/// per flavor by [`upsert_inner`]) and drives the ingest / seal / drain /
561/// capability loop described in the module docs, calling through the arm at
562/// the few points where the flavors diverge.
563#[allow(clippy::disallowed_methods)]
564fn build_upsert_operator<'scope, A, T, FromTime>(
565    input: VecCollection<'scope, T, (UpsertKey, Option<UpsertValue>, FromTime), Diff>,
566    resume_upper: Antichain<T>,
567    persist_arranged: Arranged<'scope, TraceAgent<A::Spine>>,
568    persist_token: Option<Vec<PressOnDropButton>>,
569    upsert_metrics: UpsertMetrics,
570    source_config: crate::source::SourceExportCreationConfig,
571) -> (
572    VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
573    StreamVec<'scope, T, (Option<GlobalId>, HealthStatusUpdate)>,
574    StreamVec<'scope, T, Infallible>,
575    PressOnDropButton,
576)
577where
578    A: UpsertStashArm<T, FromTime::Order>,
579    T: Timestamp + TotalOrder + Sync,
580    T: Refines<mz_repr::Timestamp> + differential_dataflow::lattice::Lattice,
581    T: columnation::Columnation,
582    T: columnar::Columnar + Default,
583    for<'a> columnar::Ref<'a, T>: Copy + Ord,
584    FromTime: Debug + timely::ExchangeData + Clone + Ord + Sync,
585    FromTime: UpsertSourceTime,
586{
587    let mut persist_trace = persist_arranged.trace.clone();
588
589    // Probe the persist arrangement's stream for frontier tracking.
590    // This replaces receiving the batch stream as an input — we just
591    // read the probe frontier to know when persist has caught up.
592    use timely::dataflow::operators::Probe;
593    let (persist_probe, _persist_probe_stream) = persist_arranged.stream.probe();
594
595    // Build the async processing operator.
596    let mut builder = AsyncOperatorBuilder::new("Upsert V2".to_string(), input.scope());
597
598    let (output_handle, output) = builder
599        .new_output::<FueledBuilder<CapacityContainerBuilder<Vec<(UpsertValue, T, Diff)>>>>();
600    let (_snapshot_handle, snapshot_stream) =
601        builder.new_output::<CapacityContainerBuilder<Vec<Infallible>>>();
602    let (_health_output, health_stream) = builder
603        .new_output::<CapacityContainerBuilder<Vec<(Option<GlobalId>, HealthStatusUpdate)>>>();
604
605    let mut input = builder.new_input_for(
606        input.inner,
607        Exchange::new(move |((key, _, _), _, _)| UpsertKey::hashed(key)),
608        &output_handle,
609    );
610
611    // We still need the persist stream as an input so the operator wakes
612    // when the persist arrangement produces batches (frontier advances).
613    // We read the actual frontier from the probe though.
614    let mut persist_wakeup = builder.new_disconnected_input(_persist_probe_stream, Pipeline);
615
616    let shutdown_button = builder.build(move |caps| async move {
617        // Hold the persist source tokens for the operator's lifetime so the
618        // feedback shard stays open until shutdown.
619        let _persist_token = persist_token;
620
621        let [output_cap, snapshot_cap, _health_cap]: [_; 3] = caps.try_into().unwrap();
622        drop(output_cap);
623        let mut snapshot_cap = CapabilitySet::from_elem(snapshot_cap);
624
625        let mut hydrating = true;
626
627        // Source stash. The batcher maintains geometrically-sized sorted
628        // chains and consolidates via the UpsertDiff Semigroup as data is
629        // pushed in, bounding memory to O(unique key-time pairs) even during
630        // large initial snapshots. How cold stash state leaves RSS is
631        // flavor-specific; see the arm impls.
632        let mut batcher = A::new_batcher();
633        // The chunker sorts and consolidates raw input into the `Column` chunks
634        // the batcher consumes.
635        let mut chunker: UpsertChunker<T, FromTime::Order> = Default::default();
636        // Scratch buffer for accumulating source events before flushing to
637        // the batcher. Drained on each iteration via the chunker.
638        let mut push_buffer: Vec<UpsertUpdate<T, FromTime::Order>> = Vec::new();
639
640        // Capability held at the minimum time of any buffered data. When
641        // Some, the operator may still produce output; when None, the
642        // batcher is empty.
643        let mut stash_cap: Option<Capability<T>> = None;
644        let mut input_upper = Antichain::from_elem(Timestamp::minimum());
645
646        let snapshot_start = std::time::Instant::now();
647        let mut prev_persist_upper = Antichain::from_elem(Timestamp::minimum());
648
649        // Accumulators for rehydration metrics, set as gauges when rehydration completes.
650        let mut rehydration_total: u64 = 0;
651        let mut rehydration_updates: u64 = 0;
652
653        // Main operator loop. Each iteration performs four steps:
654        //   Step 1: Ingest source data into the batcher.
655        //   Step 2: Read the persist frontier and update rehydration state.
656        //   Step 3: Seal the batcher, drain eligible entries, push back the rest.
657        //   Step 4: Manage the output capability.
658        loop {
659            // Block until woken by source input or a persist frontier advance.
660            tokio::select! {
661                _ = input.ready() => {}
662                _ = persist_wakeup.ready() => {
663                    while persist_wakeup.next_sync().is_some() {}
664                }
665            }
666
667            // Step 1: Ingest source data.
668            // Read all available source events, wrap each value in an
669            // UpsertDiff (carrying FromTime for dedup), and buffer them.
670            // Events before the resume_upper are dropped (already persisted).
671            while let Some(event) = input.next_sync() {
672                match event {
673                    AsyncEvent::Data(cap, data) => {
674                        let mut pushed_any = false;
675                        for ((key, value, from_time), ts, diff) in data {
676                            assert!(diff.is_positive(), "invalid upsert input");
677                            if PartialOrder::less_equal(&input_upper, &resume_upper)
678                                && !resume_upper.less_equal(&ts)
679                            {
680                                continue;
681                            }
682                            let value = value.as_ref().map(upsert_value_to_row);
683                            let from_time = from_time.upsert_order();
684                            push_buffer.push((key, ts, UpsertDiff { from_time, value }));
685                            pushed_any = true;
686                        }
687                        // Track the minimum capability across all buffered data
688                        // so we can emit output at the correct times.
689                        if pushed_any {
690                            stash_cap = Some(match stash_cap {
691                                Some(prev) if cap.time() < prev.time() => cap,
692                                Some(prev) => prev,
693                                None => cap,
694                            });
695                        }
696                    }
697                    AsyncEvent::Progress(upper) => {
698                        if PartialOrder::less_than(&upper, &resume_upper) {
699                            continue;
700                        }
701                        input_upper = upper;
702                    }
703                }
704            }
705
706            // Flush buffered events through the chunker into the batcher. This
707            // triggers the chunker + geometric chain merging, which consolidates
708            // entries for the same (key, time) via the UpsertDiff Semigroup.
709            A::flush(&mut push_buffer, &mut chunker, &mut batcher);
710
711            // Step 2: Read persist frontier.
712            // The persist probe tells us which output times have been
713            // committed back through the feedback loop. This determines:
714            //   - Whether rehydration is complete (persist >= resume_upper).
715            //   - Which source entries are eligible for processing (their
716            //     time must equal persist_upper so the feedback trace holds
717            //     the correct prior state).
718            //   - How far to compact the persist trace.
719            let persist_upper = persist_probe.with_frontier(|f| f.to_owned());
720
721            if persist_upper != prev_persist_upper {
722                let last_rehydration_chunk =
723                    hydrating && PartialOrder::less_equal(&resume_upper, &persist_upper);
724
725                if last_rehydration_chunk {
726                    hydrating = false;
727                    upsert_metrics
728                        .rehydration_latency
729                        .set(snapshot_start.elapsed().as_secs_f64());
730                    upsert_metrics.rehydration_total.set(rehydration_total);
731                    upsert_metrics.rehydration_updates.set(rehydration_updates);
732                    tracing::info!(
733                        worker_id = %source_config.worker_id,
734                        source_id = %source_config.id,
735                        "upsert finished rehydration",
736                    );
737                    snapshot_cap.downgrade(&[]);
738                }
739
740                let _ = snapshot_cap.try_downgrade(persist_upper.iter());
741
742                // Compact the trace so the spine can merge old batches.
743                persist_trace.set_logical_compaction(persist_upper.borrow());
744                persist_trace.set_physical_compaction(persist_upper.borrow());
745
746                prev_persist_upper = persist_upper.clone();
747            }
748
749            // Step 3: Seal & drain.
750            // Seal the batcher at input_upper to extract all source-finalized
751            // entries as sorted, consolidated chunks. The seal merges all
752            // internal chains (O(N) linear merge of sorted data) and splits
753            // by time: entries at ts < input_upper are extracted, the rest
754            // stay in the batcher.
755            //
756            // Extracted entries are partitioned into:
757            //   - Eligible (ts == persist_upper): processed now via a
758            //     prior-value lookup in the persist trace.
759            //   - Ineligible (persist_upper < ts < input_upper): persist
760            //     hasn't caught up yet, so pushed back into the batcher.
761            //
762            // We skip the seal entirely unless an eligible entry is at all
763            // possible. `seal` performs an O(N) merge of all chains
764            // regardless of how much it extracts, so calling it when nothing
765            // can be processed makes the operator quadratic in the number of
766            // wakeups (a real pathology during upstream snapshots and during
767            // rehydration when the source races ahead of persist).
768            //
769            // For an entry at `ts` to be eligible we need
770            // `ts == persist_upper && ts < input_upper`. The necessary
771            // preconditions, expressible without scanning the batcher:
772            //   1. `cap.time() <= persist_upper`. Since `cap.time()` is
773            //      maintained as a lower bound on `min(ts in batcher)`, if
774            //      `cap.time() > persist_upper` then every buffered ts is
775            //      strictly above persist_upper and none can equal it.
776            //   2. `persist_upper < input_upper`. Otherwise no `ts` that
777            //      satisfies `ts == persist_upper` can also satisfy
778            //      `ts < input_upper`.
779            //
780            // This naturally covers both the post-hydration source-snapshot
781            // case (cap == persist == input → condition 2 fails) and the
782            // rehydration-with-source-ahead case (cap > persist → condition
783            // 1 fails). It also no-ops correctly when persist has shut down
784            // (empty persist_upper makes condition 2 vacuously false).
785            if let Some(cap) = stash_cap.as_mut()
786                && !persist_upper.less_than(cap.time())
787                && PartialOrder::less_than(&persist_upper, &input_upper)
788            {
789                // Step 1 already consolidated `push_buffer` through the chunker
790                // (which readies a complete chunk per `push_into`), so the
791                // chunker holds nothing pending here and we can seal directly.
792                let (sealed, _description) = batcher.seal(input_upper.clone());
793                // Frontier of data remaining in the batcher (ts >= input_upper).
794                let remaining_frontier = batcher.frontier().to_owned();
795
796                let mut ineligible = Vec::new();
797                // The drain emits eligible output directly through
798                // `output_handle` (fueled), so there is no intermediate
799                // output buffer to drain afterward.
800                let drain_stats = A::drain(
801                    sealed,
802                    &mut ineligible,
803                    &output_handle,
804                    &*cap,
805                    &persist_upper,
806                    &mut persist_trace,
807                    source_config.worker_id,
808                    source_config.id,
809                )
810                .await;
811
812                upsert_metrics.multi_get_size.inc_by(drain_stats.eligible);
813                upsert_metrics
814                    .multi_get_result_count
815                    .inc_by(drain_stats.result_count);
816                upsert_metrics
817                    .multi_put_size
818                    .inc_by(drain_stats.output_count);
819                upsert_metrics.upsert_inserts.inc_by(drain_stats.inserts);
820                upsert_metrics.upsert_updates.inc_by(drain_stats.updates);
821                upsert_metrics.upsert_deletes.inc_by(drain_stats.deletes);
822
823                if hydrating {
824                    rehydration_total += drain_stats.inserts;
825                    rehydration_updates += drain_stats.eligible;
826                }
827
828                // Step 4: Capability management.
829                // Downgrade the output capability to the minimum time of any
830                // remaining data: either entries still in the batcher (above
831                // input_upper) or ineligible entries being pushed back.
832                let min_ineligible_ts = ineligible.iter().map(|(_, ts, _)| ts).min().cloned();
833                A::flush(&mut ineligible, &mut chunker, &mut batcher);
834
835                // `Option::min` alone would be wrong here, `None` sorts low.
836                // Chain the candidates and take the min over present ones.
837                let min_ts = remaining_frontier
838                    .elements()
839                    .first()
840                    .into_iter()
841                    .chain(min_ineligible_ts.as_ref())
842                    .min();
843                match min_ts {
844                    Some(min_ts) => cap.downgrade(min_ts),
845                    // Batcher is completely empty. Drop the capability so
846                    // downstream operators can make progress.
847                    None => stash_cap = None,
848                }
849            }
850
851            if input_upper.is_empty() {
852                break;
853            }
854        }
855    });
856
857    (
858        output
859            .as_collection()
860            .map(|result: UpsertValue| match result {
861                Ok(ok) => Ok(ok),
862                Err(err) => Err(DataflowError::from(EnvelopeError::Upsert(*err))),
863            }),
864        health_stream,
865        snapshot_stream,
866        shutdown_button.press_on_drop(),
867    )
868}
869
870/// The flavor-specific pieces of the upsert-v2 operator: the stash batcher,
871/// the feedback arrangement's spine, and how the drain reads prior state.
872/// [`build_upsert_operator`] holds the flavor-independent operator loop and
873/// calls through this trait at the few points where the flavors diverge.
874trait UpsertStashArm<T, O>
875where
876    T: Timestamp + Lattice + columnar::Columnar + Default,
877    for<'a> columnar::Ref<'a, T>: Copy + Ord,
878    O: columnar::Columnar + Default + Ord + Clone + Send + Sync + 'static,
879    for<'a> columnar::Ref<'a, O>: Ord + Copy,
880{
881    /// The feedback arrangement's spine. `'static` because the operator
882    /// future owns a trace agent for it.
883    type Spine: TraceReader<Time = T> + 'static;
884    /// The source-stash batcher. `'static` because the operator future owns
885    /// it.
886    type Batcher: Batcher<Time = T> + 'static;
887
888    /// A new stash batcher for one source dataflow.
889    fn new_batcher() -> Self::Batcher;
890
891    /// Push one sorted, consolidated `Column` chunk into the batcher, in the
892    /// batcher's chunk representation.
893    fn push_chunk(batcher: &mut Self::Batcher, chunk: Column<UpsertUpdate<T, O>>);
894
895    /// Consolidate `updates` through `chunker` into `Column` chunks and push
896    /// them into `batcher`, emptying `updates` (keeping its capacity). The
897    /// chunker readies a fully-consolidated chunk per `push_into`, so the
898    /// `extract` loop drains everything it produced.
899    fn flush(
900        updates: &mut Vec<UpsertUpdate<T, O>>,
901        chunker: &mut UpsertChunker<T, O>,
902        batcher: &mut Self::Batcher,
903    ) {
904        use timely::container::{ContainerBuilder as _, PushInto as _};
905        if updates.is_empty() {
906            return;
907        }
908        let mut raw: Column<UpsertUpdate<T, O>> = Default::default();
909        for update in updates.drain(..) {
910            raw.push_into(&update);
911        }
912        chunker.push_into(&mut raw);
913        while let Some(chunk) = chunker.extract() {
914            Self::push_chunk(batcher, std::mem::take(chunk));
915        }
916    }
917
918    /// Classify one sealed stash against `persist_upper` and emit eligible
919    /// output; see [`DrainStats`].
920    async fn drain(
921        sealed: Vec<<Self::Batcher as Batcher>::Output>,
922        ineligible: &mut Vec<UpsertUpdate<T, O>>,
923        output_handle: &UpsertOutputHandle<T>,
924        output_cap: &Capability<T>,
925        persist_upper: &Antichain<T>,
926        trace: &mut TraceAgent<Self::Spine>,
927        worker_id: usize,
928        source_id: GlobalId,
929    ) -> DrainStats;
930}
931
932/// The chunked flavor: [`UpsertChunkBatcher`] stash, [`FeedbackSpine`]
933/// feedback arrangement, bulk-probe drain.
934///
935/// NOTE: the seal's chain merge loads the bodies of the chunks it actually
936/// merges, while untouched survivors keep their spilled bodies. The
937/// partition against the upper passes chunks whose resident time bounds fall
938/// entirely on one side through without loading them, so only chunks the
939/// upper splits round-trip the pool codec. The drain then loads sealed
940/// chunks one at a time, so a large drain (a frontier advance releasing a
941/// snapshot's worth of stash at once) holds at most one chunk resident
942/// rather than the whole backlog.
943struct ChunkedArm;
944
945impl<T, O> UpsertStashArm<T, O> for ChunkedArm
946where
947    T: Timestamp + TotalOrder + Lattice + Sync,
948    T: columnation::Columnation + columnar::Columnar + Default,
949    for<'a> columnar::Ref<'a, T>: Copy + Ord,
950    O: columnar::Columnar + Default + Ord + Clone + Send + Sync + 'static,
951    for<'a> columnar::Ref<'a, O>: Ord + Copy,
952{
953    type Spine = FeedbackSpine<T>;
954    type Batcher = UpsertChunkBatcher<T, O>;
955
956    fn new_batcher() -> Self::Batcher {
957        Batcher::new(None, 0)
958    }
959
960    fn push_chunk(batcher: &mut Self::Batcher, chunk: Column<UpsertUpdate<T, O>>) {
961        batcher.push_into(ColumnChunk::from_column(chunk));
962    }
963
964    async fn drain(
965        sealed: Vec<UpsertChunk<T, O>>,
966        ineligible: &mut Vec<UpsertUpdate<T, O>>,
967        output_handle: &UpsertOutputHandle<T>,
968        output_cap: &Capability<T>,
969        persist_upper: &Antichain<T>,
970        trace: &mut TraceAgent<Self::Spine>,
971        worker_id: usize,
972        source_id: GlobalId,
973    ) -> DrainStats {
974        drain_sealed_input_chunked(
975            sealed.into_iter().map(ColumnChunk::into_column),
976            ineligible,
977            output_handle,
978            output_cap,
979            persist_upper,
980            trace,
981            worker_id,
982            source_id,
983        )
984        .await
985    }
986}
987
988/// The paged flavor: [`UpsertPagedBatcher`] stash, `ValRowSpine` feedback
989/// arrangement, cursor drain. Cold chains page out of RSS through the
990/// storage-owned column pager, captured once per batcher at construction.
991struct PagedArm;
992
993impl<T, O> UpsertStashArm<T, O> for PagedArm
994where
995    T: Timestamp + TotalOrder + Lattice + Sync,
996    T: columnation::Columnation + columnar::Columnar + Default,
997    for<'a> columnar::Ref<'a, T>: Copy + Ord,
998    O: columnar::Columnar + Default + Ord + Clone + Send + Sync + 'static,
999    for<'a> columnar::Ref<'a, O>: Ord + Copy,
1000{
1001    type Spine = ValRowSpine<UpsertKey, T, Diff>;
1002    type Batcher = UpsertPagedBatcher<T, O>;
1003
1004    fn new_batcher() -> Self::Batcher {
1005        let mut batcher: UpsertPagedBatcher<T, O> = Batcher::new(None, 0);
1006        batcher.set_pager(crate::upsert::upsert_stash_pager::pager());
1007        batcher
1008    }
1009
1010    fn push_chunk(batcher: &mut Self::Batcher, chunk: Column<UpsertUpdate<T, O>>) {
1011        batcher.push_into(chunk);
1012    }
1013
1014    async fn drain(
1015        sealed: Vec<Column<UpsertUpdate<T, O>>>,
1016        ineligible: &mut Vec<UpsertUpdate<T, O>>,
1017        output_handle: &UpsertOutputHandle<T>,
1018        output_cap: &Capability<T>,
1019        persist_upper: &Antichain<T>,
1020        trace: &mut TraceAgent<Self::Spine>,
1021        worker_id: usize,
1022        source_id: GlobalId,
1023    ) -> DrainStats {
1024        drain_sealed_input_paged(
1025            sealed,
1026            ineligible,
1027            output_handle,
1028            output_cap,
1029            persist_upper,
1030            trace,
1031            worker_id,
1032            source_id,
1033        )
1034        .await
1035    }
1036}
1037
1038/// Where a stashed entry's time falls relative to the feedback frontier.
1039enum TimeClass {
1040    /// `ts < persist_upper`: already persisted, dropped by the drain.
1041    AlreadyPersisted,
1042    /// `ts == persist_upper`: processed now.
1043    Eligible,
1044    /// `ts > persist_upper`: re-stashed until persist catches up.
1045    Ineligible,
1046}
1047
1048/// Classify `ts` against `persist_upper`: the single spelling of the drain's
1049/// eligibility test. The chunked drain's probe-collection pass and its
1050/// classification pass, and the paged drain's cursor walk, all call this, so
1051/// the probe set and the classification cannot disagree. Under the
1052/// operator's total order, `Eligible` means `ts` equals the frontier's one
1053/// element.
1054fn classify_time<T: PartialOrder>(persist_upper: &Antichain<T>, ts: &T) -> TimeClass {
1055    if !persist_upper.less_equal(ts) {
1056        TimeClass::AlreadyPersisted
1057    } else if persist_upper.less_than(ts) {
1058        TimeClass::Ineligible
1059    } else {
1060        TimeClass::Eligible
1061    }
1062}
1063
1064/// Counts from a single call to [`drain_sealed_input_chunked`] or
1065/// [`drain_sealed_input_paged`], used to update metrics.
1066struct DrainStats {
1067    /// Number of eligible entries probed against the feedback trace.
1068    eligible: u64,
1069    /// Number of probed entries for which a prior value was found.
1070    result_count: u64,
1071    /// New value written with no prior value (insert).
1072    inserts: u64,
1073    /// New value written over an existing value (update).
1074    updates: u64,
1075    /// Tombstone (None) applied to an existing value (delete).
1076    deletes: u64,
1077    /// Total output records emitted (retractions + insertions).
1078    output_count: u64,
1079}
1080
1081/// Process sealed chunks from the batcher, classifying each entry by its
1082/// timestamp relative to `persist_upper`:
1083///
1084///   * `ts == persist_upper`: eligible for processing now (bulk probe of the
1085///     feedback trace + output).
1086///   * `ts >  persist_upper`: not yet processable. Returned in `ineligible`
1087///     for re-stashing until the feedback frontier catches up to it.
1088///   * `ts <  persist_upper`: already persisted by some writer and not
1089///     relevant anymore, so DROPPED. The downstream persist_sink would filter
1090///     such updates out anyway since the shard upper is further ahead, and
1091///     our state is already up-to-date to `persist_upper` so we could not
1092///     emit correct retractions for it. Re-stashing it would strand the data
1093///     forever (`persist_upper` only advances, so `ts == persist_upper` can
1094///     never again hold) and pin the operator's output frontier below the
1095///     shard upper. This mirrors v1's `relevant = persist_upper.less_equal(ts)`.
1096///
1097/// The sealed chunks are already sorted and consolidated by the merge
1098/// batcher, so each chunk's eligible keys form a sorted, deduplicated probe
1099/// set, and the prior state comes back through one bulk `extract_into` pass
1100/// per trace batch. Resident fence metadata selects the touched trace
1101/// chunks, and only those bodies are read back, copy-out, per chunk probed.
1102/// Sealed chunks are pulled from the iterator one at a time and dropped
1103/// before the next is requested, so at most one loaded stash chunk (plus the
1104/// probe hits for its keys) is resident regardless of drain size. Only the
1105/// re-stashed ineligible set is materialized.
1106async fn drain_sealed_input_chunked<T, O>(
1107    sealed: impl Iterator<Item = Column<UpsertUpdate<T, O>>>,
1108    ineligible: &mut Vec<UpsertUpdate<T, O>>,
1109    output_handle: &UpsertOutputHandle<T>,
1110    output_cap: &Capability<T>,
1111    persist_upper: &Antichain<T>,
1112    trace: &mut TraceAgent<FeedbackSpine<T>>,
1113    worker_id: usize,
1114    source_id: GlobalId,
1115) -> DrainStats
1116where
1117    T: Timestamp + TotalOrder + Lattice + Sync,
1118    T: columnation::Columnation + columnar::Columnar + Default,
1119    for<'a> columnar::Ref<'a, T>: Copy + Ord,
1120    O: columnar::Columnar,
1121{
1122    let mut eligible_count: u64 = 0;
1123    let mut result_count: u64 = 0;
1124    let mut output_count: u64 = 0;
1125    let mut inserts: u64 = 0;
1126    let mut updates: u64 = 0;
1127    let mut deletes: u64 = 0;
1128
1129    // The batches this drain reads against. `Rc` clones of the trace's
1130    // sealed chunk batches: chunk bodies stay spilled and are read back
1131    // copy-out, per probed chunk, inside `extract_into`.
1132    let batches = trace
1133        .batches_through(Antichain::new().borrow())
1134        .expect("complete batch set for the feedback trace; is it closed?");
1135
1136    // Eligible keys are probed against the trace in windows of this many
1137    // distinct keys, bounding what one pass stages resident: probe hits carry
1138    // full values, so an unwindowed pass over a byte-graded chunk of small
1139    // records could stage tens of thousands of values at once.
1140    const PROBE_WINDOW: usize = 1024;
1141
1142    for chunk in sealed {
1143        use columnar::{Index, Len};
1144        let view = chunk.borrow();
1145        let total = view.len();
1146        let mut start = 0;
1147        while start < total {
1148            // Pass 1: this window's sorted, deduplicated probe keys. The
1149            // chunk is sorted by (key, time), so a window is a contiguous
1150            // record range, probes come out sorted, and dedup is a neighbor
1151            // test. The window closes where its PROBE_WINDOW + 1st distinct
1152            // eligible key would begin.
1153            let mut probe_col = <UpsertKey as columnar::Columnar>::Container::default();
1154            let mut probe_count = 0usize;
1155            let mut end = total;
1156            {
1157                use columnar::Push;
1158                let mut last_probe: Option<&UpsertKey> = None;
1159                for index in start..total {
1160                    let (key, ts, _diff) = view.get(index);
1161                    let ts = <T as columnar::Columnar>::into_owned(ts);
1162                    if matches!(classify_time(persist_upper, &ts), TimeClass::Eligible) {
1163                        if last_probe != Some(key) {
1164                            if probe_count == PROBE_WINDOW {
1165                                end = index;
1166                                break;
1167                            }
1168                            probe_col.push(key);
1169                            probe_count += 1;
1170                            last_probe = Some(key);
1171                        }
1172                    }
1173                }
1174            }
1175
1176            // Pass 2: bulk-probe the trace's batches through the chunk
1177            // batches' `UnloadChunk` surface and consolidate the hits into
1178            // the prior value per key. Hits arrive per batch, so equal
1179            // `(key, val)` pairs from different batches are non-adjacent.
1180            // Sort before folding.
1181            let mut old_values: std::collections::BTreeMap<UpsertKey, UpsertValue> =
1182                std::collections::BTreeMap::new();
1183            if probe_count > 0 {
1184                use columnar::Borrow;
1185                let mut staging = <FeedbackUpdate<T> as columnar::Columnar>::Container::default();
1186                for batch in &batches {
1187                    batch.extract_into(probe_col.borrow(), &mut staging);
1188                }
1189                let staged = staging.borrow();
1190                let mut hits: Vec<_> = (0..staged.len())
1191                    .map(|i| {
1192                        let ((key, val), _time, diff) = staged.get(i);
1193                        (key, val, <Diff as columnar::Columnar>::into_owned(diff))
1194                    })
1195                    .collect();
1196                hits.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
1197                let mut i = 0;
1198                while i < hits.len() {
1199                    let (key, val, _) = hits[i];
1200                    let mut count = Diff::ZERO;
1201                    let mut j = i;
1202                    while j < hits.len() && hits[j].0 == key && hits[j].1 == val {
1203                        count += hits[j].2;
1204                        j += 1;
1205                    }
1206                    if count.is_positive() {
1207                        assert!(
1208                            count == 1.into(),
1209                            "unexpected multiple entries for the same key in persist trace"
1210                        );
1211                        let prev = old_values.insert(*key, decode_upsert_value(val.iter()));
1212                        assert!(
1213                            prev.is_none(),
1214                            "unexpected multiple values for the same key in persist trace"
1215                        );
1216                    }
1217                    i = j;
1218                }
1219            }
1220
1221            // Pass 3: classify and emit this window's records.
1222            for index in start..end {
1223                let (key, ts, diff) = view.get(index);
1224                let ts = <T as columnar::Columnar>::into_owned(ts);
1225                match classify_time(persist_upper, &ts) {
1226                    TimeClass::AlreadyPersisted => continue,
1227                    TimeClass::Ineligible => {
1228                        // Re-stash for later (owned).
1229                        ineligible.push((
1230                            *key,
1231                            ts,
1232                            <UpsertDiff<O> as columnar::Columnar>::into_owned(diff),
1233                        ));
1234                        continue;
1235                    }
1236                    TimeClass::Eligible => {}
1237                }
1238
1239                // ts == persist_upper: eligible. The chunk holds one entry per
1240                // (key, time) and eligibility pins the time, so this key appears
1241                // at most once and its prior value can move out of the map.
1242                eligible_count += 1;
1243                let old_value = old_values.remove(key);
1244
1245                if old_value.is_some() {
1246                    result_count += 1;
1247                }
1248
1249                match diff.value {
1250                    Some(row) => {
1251                        if let Some(old_val) = old_value {
1252                            let size = upsert_value_byte_len(&old_val);
1253                            output_handle
1254                                .give_fueled(
1255                                    output_cap,
1256                                    (old_val, ts.clone(), Diff::MINUS_ONE),
1257                                    size,
1258                                )
1259                                .await;
1260                            output_count += 1;
1261                            updates += 1;
1262                        } else {
1263                            inserts += 1;
1264                        }
1265                        let new_val = decode_upsert_value(row.iter());
1266                        let size = upsert_value_byte_len(&new_val);
1267                        output_handle
1268                            .give_fueled(output_cap, (new_val, ts, Diff::ONE), size)
1269                            .await;
1270                        output_count += 1;
1271                    }
1272                    None => {
1273                        if let Some(old_val) = old_value {
1274                            let size = upsert_value_byte_len(&old_val);
1275                            output_handle
1276                                .give_fueled(output_cap, (old_val, ts, Diff::MINUS_ONE), size)
1277                                .await;
1278                            output_count += 1;
1279                            deletes += 1;
1280                        }
1281                    }
1282                }
1283            }
1284            start = end;
1285        }
1286    }
1287
1288    tracing::debug!(
1289        worker_id = %worker_id,
1290        source_id = %source_id,
1291        ineligible = ineligible.len(),
1292        eligible = eligible_count,
1293        "drained stash",
1294    );
1295
1296    DrainStats {
1297        eligible: eligible_count,
1298        result_count,
1299        inserts,
1300        updates,
1301        deletes,
1302        output_count,
1303    }
1304}
1305
1306/// [`drain_sealed_input_chunked`]'s counterpart for the paged flavor,
1307/// classifying entries the same way but reading prior state through a trace
1308/// cursor.
1309///
1310/// The sealed chunks are already sorted and consolidated by the merge
1311/// batcher, so the trace cursor walks forward through keys in order and
1312/// seeks amortize. Entries are walked by reference rather than collecting
1313/// the eligible set into an owned Vec, and eligible values are emitted
1314/// straight from the column's `RowRef` with no owned `UpsertDiff` copy. Only
1315/// the re-stashed ineligible set is materialized.
1316async fn drain_sealed_input_paged<T, O>(
1317    sealed: Vec<Column<UpsertUpdate<T, O>>>,
1318    ineligible: &mut Vec<UpsertUpdate<T, O>>,
1319    output_handle: &UpsertOutputHandle<T>,
1320    output_cap: &Capability<T>,
1321    persist_upper: &Antichain<T>,
1322    trace: &mut TraceAgent<ValRowSpine<UpsertKey, T, Diff>>,
1323    worker_id: usize,
1324    source_id: GlobalId,
1325) -> DrainStats
1326where
1327    T: Timestamp + TotalOrder + Lattice + Sync,
1328    T: columnation::Columnation + columnar::Columnar,
1329    O: columnar::Columnar,
1330{
1331    use columnar::Index as _;
1332
1333    // Classify each entry by its timestamp relative to `persist_upper`:
1334    //
1335    //   * `ts == persist_upper`: eligible for processing now.
1336    //   * `ts >  persist_upper`: not yet processable; re-stashed (ineligible)
1337    //     until the feedback frontier catches up to it.
1338    //   * `ts <  persist_upper`: already persisted by some writer and not
1339    //     relevant anymore. We DROP it. The downstream persist_sink would
1340    //     filter such updates out anyway since the shard upper is further
1341    //     ahead, and our state is already up-to-date to `persist_upper` so we
1342    //     could not emit correct retractions for it. Re-stashing it would
1343    //     strand the data forever (`persist_upper` only advances, so
1344    //     `ts == persist_upper` can never again hold) and pin the operator's
1345    //     output frontier below the shard upper. This mirrors v1's
1346    //     `relevant = persist_upper.less_equal(ts)`.
1347    let mut eligible_count: u64 = 0;
1348    let mut result_count: u64 = 0;
1349    let mut output_count: u64 = 0;
1350    let mut inserts: u64 = 0;
1351    let mut updates: u64 = 0;
1352    let mut deletes: u64 = 0;
1353
1354    let (mut cursor, storage) = trace.cursor();
1355
1356    for chunk in &sealed {
1357        for (key, ts, diff) in chunk.borrow().into_index_iter() {
1358            let ts = <T as columnar::Columnar>::into_owned(ts);
1359            match classify_time(persist_upper, &ts) {
1360                TimeClass::AlreadyPersisted => continue,
1361                TimeClass::Ineligible => {
1362                    // Re-stash for later (owned).
1363                    ineligible.push((
1364                        *key,
1365                        ts,
1366                        <UpsertDiff<O> as columnar::Columnar>::into_owned(diff),
1367                    ));
1368                    continue;
1369                }
1370                TimeClass::Eligible => {}
1371            }
1372
1373            // ts == persist_upper: eligible. Look up the prior value for this
1374            // key in the persist trace and emit the retraction / insertion. The
1375            // spine stores keys in a columnation arena, so we seek by the
1376            // column's borrowed `&UpsertKey` directly.
1377            eligible_count += 1;
1378            cursor.seek_key(&storage, key);
1379            let old_value = match cursor.get_key(&storage) {
1380                Some(found) if found == key => {
1381                    let mut result = None;
1382                    while let Some(val) = cursor.get_val(&storage) {
1383                        let mut count = Diff::ZERO;
1384                        cursor.map_times(&storage, |_time, d| {
1385                            count += d.clone();
1386                        });
1387                        if count.is_positive() {
1388                            assert!(
1389                                count == 1.into(),
1390                                "unexpected multiple entries for the same key in persist trace"
1391                            );
1392                            assert!(
1393                                result.is_none(),
1394                                "unexpected multiple values for the same key in persist trace"
1395                            );
1396                            result = Some(decode_upsert_value(val));
1397                        }
1398                        cursor.step_val(&storage);
1399                    }
1400                    result
1401                }
1402                _ => None,
1403            };
1404
1405            if old_value.is_some() {
1406                result_count += 1;
1407            }
1408
1409            match diff.value {
1410                Some(row) => {
1411                    if let Some(old_val) = old_value {
1412                        let size = upsert_value_byte_len(&old_val);
1413                        output_handle
1414                            .give_fueled(output_cap, (old_val, ts.clone(), Diff::MINUS_ONE), size)
1415                            .await;
1416                        output_count += 1;
1417                        updates += 1;
1418                    } else {
1419                        inserts += 1;
1420                    }
1421                    let new_val = decode_upsert_value(row.iter());
1422                    let size = upsert_value_byte_len(&new_val);
1423                    output_handle
1424                        .give_fueled(output_cap, (new_val, ts, Diff::ONE), size)
1425                        .await;
1426                    output_count += 1;
1427                }
1428                None => {
1429                    if let Some(old_val) = old_value {
1430                        let size = upsert_value_byte_len(&old_val);
1431                        output_handle
1432                            .give_fueled(output_cap, (old_val, ts, Diff::MINUS_ONE), size)
1433                            .await;
1434                        output_count += 1;
1435                        deletes += 1;
1436                    }
1437                }
1438            }
1439        }
1440    }
1441
1442    tracing::debug!(
1443        worker_id = %worker_id,
1444        source_id = %source_id,
1445        ineligible = ineligible.len(),
1446        eligible = eligible_count,
1447        "drained stash",
1448    );
1449
1450    DrainStats {
1451        eligible: eligible_count,
1452        result_count,
1453        inserts,
1454        updates,
1455        deletes,
1456        output_count,
1457    }
1458}
1459
1460#[cfg(test)]
1461mod test {
1462    use mz_ore::metrics::MetricsRegistry;
1463    use mz_persist_types::ShardId;
1464    use mz_repr::{Datum, Timestamp as MzTimestamp};
1465    use mz_storage_operators::persist_source::Subtime;
1466    use mz_storage_types::sources::SourceEnvelope;
1467    use mz_storage_types::sources::envelope::{KeyEnvelope, UpsertEnvelope, UpsertStyle};
1468    use timely::dataflow::operators::capture::Extract;
1469    use timely::dataflow::operators::{Capture, Input};
1470    use timely::progress::Timestamp;
1471
1472    use crate::metrics::StorageMetrics;
1473    use crate::metrics::upsert::UpsertMetricDefs;
1474    use crate::source::SourceExportCreationConfig;
1475    use crate::statistics::{SourceStatistics, SourceStatisticsMetricDefs};
1476
1477    use super::*;
1478
1479    // The tests drive the operator with a plain integer `FromTime` standing in
1480    // for a Kafka offset; project it to itself so dedup orders by it directly.
1481    impl UpsertSourceTime for i32 {
1482        type Order = i32;
1483        fn upsert_order(&self) -> i32 {
1484            *self
1485        }
1486    }
1487
1488    type Ts = (MzTimestamp, Subtime);
1489
1490    fn new_ts(ts: u64) -> Ts {
1491        (MzTimestamp::new(ts), Subtime::minimum())
1492    }
1493
1494    fn key(k: i64) -> UpsertKey {
1495        UpsertKey::from_key(Ok(&Row::pack_slice(&[Datum::Int64(k)])))
1496    }
1497
1498    fn row(k: i64, v: i64) -> Row {
1499        Row::pack_slice(&[Datum::Int64(k), Datum::Int64(v)])
1500    }
1501
1502    // Runs the test body once per stash flavor and asserts the two flavors
1503    // produce identical (consolidated) output, so every scenario covers both
1504    // operator arms. Returns one flavor's output for the caller's own
1505    // expected-value assertion.
1506    macro_rules! upsert_test {
1507        (|$input:ident, $persist:ident, $worker:ident| $body:block) => {{
1508            let run = |flavor: UpsertStashFlavor| {
1509                let output_handle = timely::execute_directly(move |$worker| {
1510                    let (mut $input, mut $persist, output_handle) = $worker
1511                        .dataflow::<MzTimestamp, _, _>(|scope| {
1512                            scope.scoped::<Ts, _, _>("upsert", |scope| {
1513                                let (input_handle, input) = scope.new_input();
1514                                let (persist_handle, persist_input) = scope.new_input();
1515                                let source_id = GlobalId::User(0);
1516
1517                                let reg = MetricsRegistry::new();
1518                                let upsert_defs = UpsertMetricDefs::register_with(&reg);
1519                                let upsert_metrics =
1520                                    UpsertMetrics::new(&upsert_defs, source_id, 0, None);
1521
1522                                let reg2 = MetricsRegistry::new();
1523                                let storage_metrics = StorageMetrics::register_with(&reg2);
1524
1525                                let reg3 = MetricsRegistry::new();
1526                                let stats_defs =
1527                                    SourceStatisticsMetricDefs::register_with(&reg3);
1528                                let envelope = SourceEnvelope::Upsert(UpsertEnvelope {
1529                                    source_arity: 2,
1530                                    style: UpsertStyle::Default(KeyEnvelope::Flattened),
1531                                    key_indices: vec![0],
1532                                });
1533                                let source_statistics = SourceStatistics::new(
1534                                    source_id, 0, &stats_defs, source_id, &ShardId::new(),
1535                                    envelope, Antichain::from_elem(Timestamp::minimum()),
1536                                );
1537                                let source_config = SourceExportCreationConfig {
1538                                    id: source_id,
1539                                    worker_id: 0,
1540                                    metrics: storage_metrics,
1541                                    source_statistics,
1542                                };
1543
1544                                let (output, _, _, button) = upsert_inner(
1545                                    flavor,
1546                                    input.as_collection(),
1547                                    vec![0],
1548                                    Antichain::from_elem(Timestamp::minimum()),
1549                                    persist_input.as_collection(),
1550                                    None,
1551                                    upsert_metrics,
1552                                    source_config,
1553                                );
1554                                std::mem::forget(button);
1555                                (input_handle, persist_handle, output.inner.capture())
1556                            })
1557                        });
1558
1559                    $body
1560
1561                    output_handle
1562                });
1563
1564                let mut actual: Vec<_> = output_handle
1565                    .extract()
1566                    .into_iter()
1567                    .flat_map(|(_cap, container)| container)
1568                    .collect();
1569                differential_dataflow::consolidation::consolidate_updates(&mut actual);
1570                actual
1571            };
1572
1573            let paged = run(UpsertStashFlavor::Paged);
1574            let chunked = run(UpsertStashFlavor::Chunked);
1575            assert_eq!(paged, chunked, "stash flavors must produce equal output");
1576            chunked
1577        }};
1578    }
1579
1580    #[mz_ore::test]
1581    #[cfg_attr(miri, ignore)]
1582    fn gh_9160_repro() {
1583        let actual = upsert_test!(|input, persist, worker| {
1584            let key0 = key(0);
1585            let key1 = key(1);
1586            let value1 = row(0, 0);
1587            let value3 = row(0, 1);
1588            let value4 = row(0, 2);
1589
1590            input.send(((key0, Some(Ok(value1.clone())), 1), new_ts(0), Diff::ONE));
1591            input.advance_to(new_ts(2));
1592            worker.step();
1593
1594            persist.send((Ok(value1), new_ts(0), Diff::ONE));
1595            persist.advance_to(new_ts(1));
1596            worker.step();
1597
1598            input.send_batch(&mut vec![
1599                ((key1, None, 2), new_ts(2), Diff::ONE),
1600                ((key0, Some(Ok(value3)), 3), new_ts(3), Diff::ONE),
1601            ]);
1602            input.advance_to(new_ts(3));
1603            input.send_batch(&mut vec![(
1604                (key0, Some(Ok(value4)), 4),
1605                new_ts(3),
1606                Diff::ONE,
1607            )]);
1608            input.advance_to(new_ts(4));
1609            worker.step();
1610
1611            persist.advance_to(new_ts(3));
1612            worker.step();
1613        });
1614
1615        let value1 = row(0, 0);
1616        let value4 = row(0, 2);
1617        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1618            (Ok(value1.clone()), new_ts(0), Diff::ONE),
1619            (Ok(value1), new_ts(3), Diff::MINUS_ONE),
1620            (Ok(value4), new_ts(3), Diff::ONE),
1621        ];
1622        assert_eq!(actual, expected);
1623    }
1624
1625    #[mz_ore::test]
1626    #[cfg_attr(miri, ignore)]
1627    fn out_of_order_keys_across_timestamps() {
1628        let actual = upsert_test!(|input, persist, worker| {
1629            let key_high = key(99);
1630            let key_low = key(1);
1631            let val_a = row(99, 1);
1632            let val_b = row(1, 2);
1633
1634            input.send(((key_high, Some(Ok(val_a.clone())), 1), new_ts(0), Diff::ONE));
1635            input.advance_to(new_ts(1));
1636            worker.step();
1637            persist.send((Ok(val_a.clone()), new_ts(0), Diff::ONE));
1638            persist.advance_to(new_ts(1));
1639            worker.step();
1640
1641            input.send(((key_low, Some(Ok(val_b.clone())), 2), new_ts(1), Diff::ONE));
1642            input.advance_to(new_ts(2));
1643            worker.step();
1644            persist.send((Ok(val_b.clone()), new_ts(1), Diff::ONE));
1645            persist.advance_to(new_ts(2));
1646            worker.step();
1647
1648            let val_a2 = row(99, 10);
1649            let val_b2 = row(1, 20);
1650            input.send_batch(&mut vec![
1651                (
1652                    (key_high, Some(Ok(val_a2.clone())), 3),
1653                    new_ts(2),
1654                    Diff::ONE,
1655                ),
1656                ((key_low, Some(Ok(val_b2.clone())), 4), new_ts(2), Diff::ONE),
1657            ]);
1658            input.advance_to(new_ts(3));
1659            worker.step();
1660            persist.advance_to(new_ts(3));
1661            worker.step();
1662        });
1663
1664        let val_a = row(99, 1);
1665        let val_b = row(1, 2);
1666        let val_a2 = row(99, 10);
1667        let val_b2 = row(1, 20);
1668        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1669            (Ok(val_b.clone()), new_ts(1), Diff::ONE),
1670            (Ok(val_b), new_ts(2), Diff::MINUS_ONE),
1671            (Ok(val_b2), new_ts(2), Diff::ONE),
1672            (Ok(val_a.clone()), new_ts(0), Diff::ONE),
1673            (Ok(val_a), new_ts(2), Diff::MINUS_ONE),
1674            (Ok(val_a2), new_ts(2), Diff::ONE),
1675        ];
1676        let mut actual_sorted = actual;
1677        let mut expected_sorted = expected;
1678        actual_sorted.sort();
1679        expected_sorted.sort();
1680        assert_eq!(actual_sorted, expected_sorted);
1681    }
1682
1683    #[mz_ore::test]
1684    #[cfg_attr(miri, ignore)]
1685    fn rehydration_then_update() {
1686        let actual = upsert_test!(|input, persist, worker| {
1687            let k = key(42);
1688            let old_val = row(42, 100);
1689            let new_val = row(42, 200);
1690
1691            persist.send((Ok(old_val), new_ts(0), Diff::ONE));
1692            persist.advance_to(new_ts(1));
1693            worker.step();
1694
1695            input.send(((k, Some(Ok(new_val)), 1), new_ts(1), Diff::ONE));
1696            input.advance_to(new_ts(2));
1697            worker.step();
1698            persist.advance_to(new_ts(2));
1699            worker.step();
1700        });
1701
1702        let old_val = row(42, 100);
1703        let new_val = row(42, 200);
1704        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1705            (Ok(old_val), new_ts(1), Diff::MINUS_ONE),
1706            (Ok(new_val), new_ts(1), Diff::ONE),
1707        ];
1708        assert_eq!(actual, expected);
1709    }
1710
1711    #[mz_ore::test]
1712    #[cfg_attr(miri, ignore)]
1713    fn drain_crosses_probe_window() {
1714        // More distinct keys than one drain probe window holds, all eligible
1715        // at the same timestamp. The records fit one sealed chunk (the ship
1716        // threshold is ~2 MiB), so the drain must close a probe window
1717        // mid-chunk and carry keys correctly across the boundary.
1718        const KEYS: i64 = 1500;
1719        let actual = upsert_test!(|input, persist, worker| {
1720            for k in 0..KEYS {
1721                persist.send((Ok(row(k, k)), new_ts(0), Diff::ONE));
1722            }
1723            persist.advance_to(new_ts(1));
1724            worker.step();
1725
1726            for k in 0..KEYS {
1727                input.send(((key(k), Some(Ok(row(k, k + 1))), 1), new_ts(1), Diff::ONE));
1728            }
1729            input.advance_to(new_ts(2));
1730            worker.step();
1731            persist.advance_to(new_ts(2));
1732            worker.step();
1733        });
1734
1735        let mut expected: Vec<(Result<Row, DataflowError>, _, _)> = Vec::new();
1736        for k in 0..KEYS {
1737            expected.push((Ok(row(k, k)), new_ts(1), Diff::MINUS_ONE));
1738            expected.push((Ok(row(k, k + 1)), new_ts(1), Diff::ONE));
1739        }
1740        let mut actual_sorted = actual;
1741        actual_sorted.sort();
1742        expected.sort();
1743        assert_eq!(actual_sorted, expected);
1744    }
1745
1746    /// The probe-window scenario with committed chunks forced through a
1747    /// private buffer pool, so the chunked flavor's seal and drain read
1748    /// spilled bodies back through the pool codec rather than resident
1749    /// memory. The override is thread-scoped and `execute_directly` runs the
1750    /// worker on this thread, so it reaches the operator's batchers. The
1751    /// paged flavor (which the harness also runs) routes through the column
1752    /// pager rather than the chunk override, so it stays resident and serves
1753    /// as the reference.
1754    #[mz_ore::test]
1755    #[cfg_attr(miri, ignore)]
1756    fn drain_reads_spilled_chunks() {
1757        use mz_ore::pool::Pool;
1758        use mz_timely_util::columnar::chunk::set_spill_override;
1759
1760        let pool = Pool::new().expect("pool creation");
1761        set_spill_override(Some(pool.clone()));
1762
1763        const KEYS: i64 = 1500;
1764        let actual = upsert_test!(|input, persist, worker| {
1765            for k in 0..KEYS {
1766                persist.send((Ok(row(k, k)), new_ts(0), Diff::ONE));
1767            }
1768            persist.advance_to(new_ts(1));
1769            worker.step();
1770
1771            for k in 0..KEYS {
1772                input.send(((key(k), Some(Ok(row(k, k + 1))), 1), new_ts(1), Diff::ONE));
1773            }
1774            input.advance_to(new_ts(2));
1775            worker.step();
1776            persist.advance_to(new_ts(2));
1777            worker.step();
1778        });
1779
1780        set_spill_override(None);
1781        assert!(
1782            pool.stats().inserts > 0,
1783            "chunks should have spilled through the pool"
1784        );
1785
1786        let mut expected: Vec<(Result<Row, DataflowError>, _, _)> = Vec::new();
1787        for k in 0..KEYS {
1788            expected.push((Ok(row(k, k)), new_ts(1), Diff::MINUS_ONE));
1789            expected.push((Ok(row(k, k + 1)), new_ts(1), Diff::ONE));
1790        }
1791        let mut actual_sorted = actual;
1792        actual_sorted.sort();
1793        expected.sort();
1794        assert_eq!(actual_sorted, expected);
1795    }
1796
1797    #[mz_ore::test]
1798    #[cfg_attr(miri, ignore)]
1799    fn delete_existing_key() {
1800        let actual = upsert_test!(|input, persist, worker| {
1801            let k = key(7);
1802            let val = row(7, 77);
1803
1804            input.send(((k, Some(Ok(val.clone())), 1), new_ts(0), Diff::ONE));
1805            input.advance_to(new_ts(1));
1806            worker.step();
1807            persist.send((Ok(val), new_ts(0), Diff::ONE));
1808            persist.advance_to(new_ts(1));
1809            worker.step();
1810
1811            input.send(((k, None, 2), new_ts(1), Diff::ONE));
1812            input.advance_to(new_ts(2));
1813            worker.step();
1814            persist.advance_to(new_ts(2));
1815            worker.step();
1816        });
1817
1818        let val = row(7, 77);
1819        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1820            (Ok(val.clone()), new_ts(0), Diff::ONE),
1821            (Ok(val), new_ts(1), Diff::MINUS_ONE),
1822        ];
1823        assert_eq!(actual, expected);
1824    }
1825
1826    #[mz_ore::test]
1827    #[cfg_attr(miri, ignore)]
1828    fn multi_batch_rehydration() {
1829        let actual = upsert_test!(|input, persist, worker| {
1830            let k = key(5);
1831            let old_val = row(5, 10);
1832            let new_val = row(5, 20);
1833            let updated_val = row(5, 30);
1834
1835            persist.send((Ok(old_val.clone()), new_ts(0), Diff::ONE));
1836            persist.send((Ok(old_val), new_ts(0), Diff::MINUS_ONE));
1837            persist.send((Ok(new_val), new_ts(0), Diff::ONE));
1838            persist.advance_to(new_ts(1));
1839            worker.step();
1840
1841            input.send(((k, Some(Ok(updated_val)), 1), new_ts(1), Diff::ONE));
1842            input.advance_to(new_ts(2));
1843            worker.step();
1844            persist.advance_to(new_ts(2));
1845            worker.step();
1846        });
1847
1848        let new_val = row(5, 20);
1849        let updated_val = row(5, 30);
1850        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1851            (Ok(new_val), new_ts(1), Diff::MINUS_ONE),
1852            (Ok(updated_val), new_ts(1), Diff::ONE),
1853        ];
1854        assert_eq!(actual, expected);
1855    }
1856
1857    #[mz_ore::test]
1858    #[cfg_attr(miri, ignore)]
1859    fn delete_nonexistent_key() {
1860        let actual = upsert_test!(|input, persist, worker| {
1861            let k = key(99);
1862
1863            persist.advance_to(new_ts(1));
1864            worker.step();
1865
1866            input.send(((k, None, 1), new_ts(1), Diff::ONE));
1867            input.advance_to(new_ts(2));
1868            worker.step();
1869            persist.advance_to(new_ts(2));
1870            worker.step();
1871        });
1872
1873        assert!(actual.is_empty(), "expected empty output, got: {actual:?}");
1874    }
1875
1876    #[mz_ore::test]
1877    #[cfg_attr(miri, ignore)]
1878    fn reinsert_after_delete() {
1879        let actual = upsert_test!(|input, persist, worker| {
1880            let k = key(3);
1881            let val_a = row(3, 10);
1882            let val_b = row(3, 20);
1883
1884            input.send(((k, Some(Ok(val_a.clone())), 1), new_ts(0), Diff::ONE));
1885            input.advance_to(new_ts(1));
1886            worker.step();
1887            persist.send((Ok(val_a.clone()), new_ts(0), Diff::ONE));
1888            persist.advance_to(new_ts(1));
1889            worker.step();
1890
1891            input.send(((k, None, 2), new_ts(1), Diff::ONE));
1892            input.advance_to(new_ts(2));
1893            worker.step();
1894            persist.send((Ok(val_a), new_ts(1), Diff::MINUS_ONE));
1895            persist.advance_to(new_ts(2));
1896            worker.step();
1897
1898            input.send(((k, Some(Ok(val_b.clone())), 3), new_ts(2), Diff::ONE));
1899            input.advance_to(new_ts(3));
1900            worker.step();
1901            persist.advance_to(new_ts(3));
1902            worker.step();
1903        });
1904
1905        let val_a = row(3, 10);
1906        let val_b = row(3, 20);
1907        let mut expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1908            (Ok(val_a.clone()), new_ts(0), Diff::ONE),
1909            (Ok(val_a), new_ts(1), Diff::MINUS_ONE),
1910            (Ok(val_b), new_ts(2), Diff::ONE),
1911        ];
1912        expected.sort();
1913        let mut actual = actual;
1914        actual.sort();
1915        assert_eq!(actual, expected);
1916    }
1917
1918    #[mz_ore::test]
1919    #[cfg_attr(miri, ignore)]
1920    fn idempotent_update() {
1921        let actual = upsert_test!(|input, persist, worker| {
1922            let k = key(11);
1923            let val = row(11, 50);
1924
1925            input.send(((k, Some(Ok(val.clone())), 1), new_ts(0), Diff::ONE));
1926            input.advance_to(new_ts(1));
1927            worker.step();
1928            persist.send((Ok(val.clone()), new_ts(0), Diff::ONE));
1929            persist.advance_to(new_ts(1));
1930            worker.step();
1931
1932            input.send(((k, Some(Ok(val.clone())), 2), new_ts(1), Diff::ONE));
1933            input.advance_to(new_ts(2));
1934            worker.step();
1935            persist.advance_to(new_ts(2));
1936            worker.step();
1937        });
1938
1939        let val = row(11, 50);
1940        let expected: Vec<(Result<Row, DataflowError>, _, _)> =
1941            vec![(Ok(val), new_ts(0), Diff::ONE)];
1942        assert_eq!(actual, expected);
1943    }
1944
1945    /// Operator-level repro of the 0dt read-only-handoff stranding bug.
1946    ///
1947    /// Models a lagging replacement generation: the external (old) writer has
1948    /// already advanced the shard — and therefore the feedback `persist_upper`
1949    /// — to `T = 10`, while the operator itself has emitted nothing. The
1950    /// lagging replacement now produces source data at timestamps BELOW that
1951    /// upper (`ts = 5, 7`), i.e. data the external writer has already persisted.
1952    ///
1953    /// The drain DROPS such already-persisted data (it satisfies
1954    /// neither `ts == persist_upper` nor `ts > persist_upper`), mirroring v1's
1955    /// `relevant = persist_upper.less_equal(ts)`. Were it instead re-stashed,
1956    /// the data would be stranded forever — `persist_upper` only advances, so
1957    /// `ts == persist_upper` could never again hold — and `min_ineligible_ts`
1958    /// would pin the operator's output capability at `ts = 5`, BELOW the shard
1959    /// upper, where it would stay for good. Dropping it lets the frontier
1960    /// advance freely.
1961    #[mz_ore::test]
1962    #[cfg_attr(miri, ignore)]
1963    fn lagging_replacement_below_upper_strands_data() {
1964        for flavor in [UpsertStashFlavor::Paged, UpsertStashFlavor::Chunked] {
1965            let (frontier, emitted) = run_below_upper_scenario_v2(flavor);
1966
1967            // The below-upper data is discarded (no output) and the output
1968            // frontier is not pinned below the shard upper (10); it advances
1969            // to the input upper (11), matching v1's behavior.
1970            assert!(
1971                emitted.is_empty(),
1972                "below-upper data should be dropped, not emitted; got {emitted:?} ({flavor:?})"
1973            );
1974            assert_eq!(
1975                frontier,
1976                vec![new_ts(11)],
1977                "v2 output frontier should advance to the input upper, not pin below \
1978                 persist_upper ({flavor:?})"
1979            );
1980            assert!(
1981                frontier[0] >= new_ts(10),
1982                "v2 output frontier {frontier:?} should reach at least persist_upper (10) \
1983                 ({flavor:?})"
1984            );
1985        }
1986    }
1987
1988    /// Shared driver for the lagging-replacement scenario against v2. Returns
1989    /// `(output_frontier, consolidated_emitted_updates)`.
1990    fn run_below_upper_scenario_v2(
1991        flavor: UpsertStashFlavor,
1992    ) -> (Vec<Ts>, Vec<(Result<Row, DataflowError>, Ts, Diff)>) {
1993        use timely::dataflow::operators::Probe;
1994
1995        let (frontier, capture) = timely::execute_directly(move |worker| {
1996            let (mut input, mut persist, probe, capture) =
1997                worker.dataflow::<MzTimestamp, _, _>(|scope| {
1998                    scope.scoped::<Ts, _, _>("upsert", |scope| {
1999                        let (input_handle, input) = scope.new_input();
2000                        let (persist_handle, persist_input) = scope.new_input();
2001                        let source_id = GlobalId::User(0);
2002
2003                        let reg = MetricsRegistry::new();
2004                        let upsert_defs = UpsertMetricDefs::register_with(&reg);
2005                        let upsert_metrics = UpsertMetrics::new(&upsert_defs, source_id, 0, None);
2006
2007                        let reg2 = MetricsRegistry::new();
2008                        let storage_metrics = StorageMetrics::register_with(&reg2);
2009
2010                        let reg3 = MetricsRegistry::new();
2011                        let stats_defs = SourceStatisticsMetricDefs::register_with(&reg3);
2012                        let envelope = SourceEnvelope::Upsert(UpsertEnvelope {
2013                            source_arity: 2,
2014                            style: UpsertStyle::Default(KeyEnvelope::Flattened),
2015                            key_indices: vec![0],
2016                        });
2017                        let source_statistics = SourceStatistics::new(
2018                            source_id,
2019                            0,
2020                            &stats_defs,
2021                            source_id,
2022                            &ShardId::new(),
2023                            envelope,
2024                            Antichain::from_elem(Timestamp::minimum()),
2025                        );
2026                        let source_config = SourceExportCreationConfig {
2027                            id: source_id,
2028                            worker_id: 0,
2029                            metrics: storage_metrics,
2030                            source_statistics,
2031                        };
2032
2033                        let (output, _, _, button) = upsert_inner(
2034                            flavor,
2035                            input.as_collection(),
2036                            vec![0],
2037                            Antichain::from_elem(Timestamp::minimum()),
2038                            persist_input.as_collection(),
2039                            None,
2040                            upsert_metrics,
2041                            source_config,
2042                        );
2043                        std::mem::forget(button);
2044                        let (probe, stream) = output.inner.probe();
2045                        (input_handle, persist_handle, probe, stream.capture())
2046                    })
2047                });
2048
2049            // The external writer has advanced the shard (feedback persist_upper)
2050            // to T = 10 WITHOUT the operator emitting anything itself.
2051            persist.advance_to(new_ts(10));
2052            for _ in 0..20 {
2053                worker.step();
2054            }
2055
2056            // The lagging replacement produces source data at ts BELOW the
2057            // current persist_upper (5 and 7 while persist_upper = 10).
2058            input.send(((key(0), Some(Ok(row(0, 1))), 1), new_ts(5), Diff::ONE));
2059            input.send(((key(1), Some(Ok(row(1, 2))), 2), new_ts(7), Diff::ONE));
2060            input.advance_to(new_ts(11));
2061            for _ in 0..20 {
2062                worker.step();
2063            }
2064
2065            (probe.with_frontier(|f| f.to_vec()), capture)
2066        });
2067
2068        let mut emitted: Vec<_> = capture
2069            .extract()
2070            .into_iter()
2071            .flat_map(|(_cap, c)| c)
2072            .collect();
2073        differential_dataflow::consolidation::consolidate_updates(&mut emitted);
2074        (frontier, emitted)
2075    }
2076}