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 is a paged columnar merge batcher: it
35//!    consolidates entries for the same `(key, time)` via the `UpsertDiff`
36//!    Semigroup — keeping the update with the highest order key (latest source
37//!    offset) — through amortized geometric merging as data is pushed in, and
38//!    pages cold chains out of RSS through the pager. This bounds resident
39//!    memory to O(unique key-time pairs) 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 `Column` chunks. Each
47//!    entry is classified:
48//!    - **Eligible** (at the persist frontier): the persist trace has the
49//!      correct "before" state for this time. Look up the old value via a
50//!      cursor, emit a retraction if present, and emit the new value.
51//!    - **Ineligible** (between persist and input frontiers): persist hasn't
52//!      caught up yet. Push back into the batcher for the next iteration.
53//!    - **Already persisted** (below the persist frontier): some writer has
54//!      already advanced the shard past this time, so it is dropped. See
55//!      `drain_sealed_input` for why re-stashing it would strand the data
56//!      and pin the output frontier below the shard upper.
57//!
58//! 4. **Capability management.** Downgrade the output capability to the
59//!    minimum time of any remaining buffered data (in the batcher or pushed
60//!    back as ineligible). Drop the capability entirely when the batcher is
61//!    empty.
62//!
63//! ## Eligibility condition (total order)
64//!
65//! For a total-order timestamp with `input_upper = {i}` and
66//! `persist_upper = {p}`, an entry at time `ts` is eligible when
67//! `ts == p < i` — the source has finalized it and persist is exactly at
68//! that time, so the trace cursor returns the correct prior state. An entry
69//! with `p < ts` is ineligible (persist hasn't caught up), and one with
70//! `ts < p` is already persisted and dropped.
71
72use std::fmt::Debug;
73
74use columnar::Index as _;
75use differential_dataflow::difference::{IsZero, Semigroup};
76use differential_dataflow::hashable::Hashable;
77use differential_dataflow::lattice::Lattice;
78use differential_dataflow::logging::Logger;
79use differential_dataflow::operators::arrange::agent::TraceAgent;
80use differential_dataflow::operators::arrange::arrangement::arrange_core;
81use differential_dataflow::trace::{Batcher, Cursor, Description, TraceReader};
82use differential_dataflow::{AsCollection, VecCollection};
83use mz_repr::{Datum, Diff, GlobalId, Row};
84use mz_row_spine::{ValRowColPagedBuilder, ValRowSpine};
85// Only the fuzzing-gated `datum_seq_to_upsert_value` takes a `DatumSeq`.
86#[cfg(feature = "fuzzing")]
87use mz_row_spine::DatumSeq;
88use mz_storage_types::errors::{DataflowError, EnvelopeError, UpsertError};
89use mz_timely_util::builder_async::{
90    AsyncOutputHandle, Event as AsyncEvent, OperatorBuilder as AsyncOperatorBuilder,
91    PressOnDropButton,
92};
93use mz_timely_util::columnar::batcher::ColumnChunker;
94use mz_timely_util::columnar::builder::ColumnBuilder;
95use mz_timely_util::columnar::merge_batcher::ColumnMergeBatcher;
96use mz_timely_util::columnar::{Col2ValPagedBatcher, Column};
97use mz_timely_util::containers::stack::FueledBuilder;
98use std::convert::Infallible;
99use timely::container::{CapacityContainerBuilder, PushInto};
100use timely::dataflow::StreamVec;
101use timely::dataflow::channels::pact::{Exchange, Pipeline};
102use timely::dataflow::operators::generic::Operator;
103use timely::dataflow::operators::{Capability, CapabilitySet, Exchange as _};
104use timely::order::{PartialOrder, TotalOrder};
105use timely::progress::frontier::AntichainRef;
106use timely::progress::timestamp::Refines;
107use timely::progress::{Antichain, Timestamp};
108
109use crate::healthcheck::HealthStatusUpdate;
110use crate::metrics::upsert::UpsertMetrics;
111use crate::upsert::UpsertKey;
112use crate::upsert::UpsertSourceTime;
113use crate::upsert::UpsertValue;
114
115/// The persist-feedback arrangement's batcher, wrapping [`Col2ValPagedBatcher`]
116/// only to capture the storage upsert-stash pager at construction.
117///
118/// `arrange_core` builds its batcher via [`Batcher::new`], which has no pager
119/// hook, so a plain `Col2ValPagedBatcher` falls back to the process-global
120/// (compute) pager — meaning the feedback arrangement's spill would be gated by
121/// compute's `enable_column_paged_batcher_spill` rather than storage's
122/// `enable_upsert_paged_spill`. Injecting `upsert_stash_pager::pager()` in `new`
123/// puts the feedback arrangement under the same flag as the source stash. Every
124/// other method delegates to the inner batcher unchanged.
125struct UpsertFeedbackBatcher<T: columnar::Columnar>(Col2ValPagedBatcher<UpsertKey, Row, T, Diff>);
126
127impl<T> Batcher for UpsertFeedbackBatcher<T>
128where
129    T: Timestamp + columnar::Columnar + Default + PartialOrder,
130    for<'a> columnar::Ref<'a, T>: Copy + Ord,
131{
132    type Output = Column<((UpsertKey, Row), T, Diff)>;
133    type Time = T;
134
135    fn new(logger: Option<Logger>, operator_id: usize) -> Self {
136        let mut batcher =
137            <Col2ValPagedBatcher<UpsertKey, Row, T, Diff> as Batcher>::new(logger, operator_id);
138        batcher.set_pager(crate::upsert::upsert_stash_pager::pager());
139        Self(batcher)
140    }
141
142    fn seal(&mut self, upper: Antichain<T>) -> (Vec<Self::Output>, Description<T>) {
143        self.0.seal(upper)
144    }
145
146    fn frontier(&mut self) -> AntichainRef<'_, T> {
147        self.0.frontier()
148    }
149}
150
151impl<T> PushInto<Column<((UpsertKey, Row), T, Diff)>> for UpsertFeedbackBatcher<T>
152where
153    T: Timestamp + columnar::Columnar + Default + PartialOrder,
154    for<'a> columnar::Ref<'a, T>: Copy + Ord,
155{
156    fn push_into(&mut self, chunk: Column<((UpsertKey, Row), T, Diff)>) {
157        self.0.push_into(chunk)
158    }
159}
160
161// The source stash carries the upsert payload in a custom diff type so the
162// merge batcher consolidates by (key, time), keeping the update with the
163// highest `FromTime` (latest source offset) per group. The diff is `Columnar`
164// so the paged merge batcher can store it in a `Column` and page it out of RSS.
165//
166// The value is a tag-encoded `Row` (see `upsert_value_to_row`) rather than an
167// `UpsertValue`: folding both the `Ok` and `Err` arms into one `Row` lets the
168// value share a single columnar byte container, and `Row` already implements
169// `Columnar`. `None` is a deletion tombstone.
170
171// Derive ordering on the generated `UpsertDiffReference` too: the paged merge
172// batcher requires `Ref: Ord` to sort the `(key, time, diff)` columns it
173// consolidates. The derived order (by `from_time`, then `value`) is fine —
174// "max FromTime wins" can tie only between equal `from_time`s, and a source
175// never emits two distinct values for the same `(key, time, from_time)`, so
176// the consolidated result doesn't depend on the fold order of equal
177// `(key, time)` runs.
178#[derive(Clone, Debug, Default, columnar::Columnar)]
179#[columnar(derive(PartialEq, Eq, PartialOrd, Ord))]
180struct UpsertDiff<O> {
181    from_time: O,
182    value: Option<Row>,
183}
184
185impl<O> IsZero for UpsertDiff<O> {
186    fn is_zero(&self) -> bool {
187        false
188    }
189}
190
191impl<O: Ord + Clone> Semigroup for UpsertDiff<O> {
192    fn plus_equals(&mut self, rhs: &Self) {
193        if rhs.from_time > self.from_time {
194            *self = rhs.clone();
195        }
196    }
197}
198
199// Accumulate a borrowed columnar reference: the paged merge batcher consolidates
200// `Column`-resident diffs through this path on every fold of an equal
201// `(key, time)` run. Materialize only the order key to decide the "max FromTime
202// wins" comparison — copying the value `Row` out of the column solely when `rhs`
203// wins. Losing folds (the common case for a repeatedly-updated key) then pay no
204// `Row` copy at all.
205impl<'a, O> Semigroup<columnar::Ref<'a, UpsertDiff<O>>> for UpsertDiff<O>
206where
207    O: columnar::Columnar + Ord + Clone,
208{
209    fn plus_equals(&mut self, rhs: &columnar::Ref<'a, UpsertDiff<O>>) {
210        let rhs_from_time = <O as columnar::Columnar>::into_owned(rhs.from_time);
211        if rhs_from_time > self.from_time {
212            self.from_time = rhs_from_time;
213            self.value = <Option<Row> as columnar::Columnar>::into_owned(rhs.value);
214        }
215    }
216}
217
218/// Consolidate `updates` through `chunker` into `Column` chunks and push them
219/// into `batcher`, emptying `updates` (keeping its capacity). The chunker
220/// readies a fully-consolidated chunk per `push_into`, so the `extract` loop
221/// drains everything it produced.
222fn flush_to_batcher<T, O>(
223    updates: &mut Vec<UpsertUpdate<T, O>>,
224    chunker: &mut UpsertChunker<T, O>,
225    batcher: &mut UpsertBatcher<T, O>,
226) where
227    T: columnar::Columnar + Default + Clone + PartialOrder,
228    for<'a> columnar::Ref<'a, T>: Copy + Ord,
229    O: columnar::Columnar + Default + Ord + Clone,
230    for<'a> columnar::Ref<'a, O>: Ord,
231{
232    use timely::container::{ContainerBuilder as _, PushInto as _};
233    if updates.is_empty() {
234        return;
235    }
236    let mut raw: Column<UpsertUpdate<T, O>> = Default::default();
237    for update in updates.drain(..) {
238        raw.push_into(&update);
239    }
240    chunker.push_into(&mut raw);
241    while let Some(chunk) = chunker.extract() {
242        batcher.push_into(std::mem::take(chunk));
243    }
244}
245
246// The source stash uses the paged columnar merge batcher. Data is pushed in
247// unsorted; the batcher maintains geometrically-sized sorted chains and
248// consolidates via the UpsertDiff Semigroup automatically. Unlike DD's
249// in-memory `VecMerger`, this batcher stores each chain entry as a `Column`
250// routed through the process-global pager, so the not-yet-eligible backlog
251// (the snapshot / persist-lag window) pages out of RSS instead of growing it.
252
253/// One source-stash update: a key, its dataflow time, and the payload diff.
254/// `O` is the columnar order key projected from the source `FromTime` (see
255/// [`UpsertSourceTime`]).
256type UpsertUpdate<T, O> = (UpsertKey, T, UpsertDiff<O>);
257
258type UpsertBatcher<T, O> = ColumnMergeBatcher<UpsertKey, T, UpsertDiff<O>>;
259
260/// The chunker that sorts and consolidates raw input into the `Column` chunks
261/// [`UpsertBatcher`] consumes.
262type UpsertChunker<T, O> = ColumnChunker<UpsertUpdate<T, O>>;
263
264/// The operator's data-output handle. A fueled `Vec` builder so the drain can
265/// `give_fueled` each emitted update and yield to timely under large snapshot
266/// drains instead of monopolizing the worker.
267type UpsertOutputHandle<T> =
268    AsyncOutputHandle<T, FueledBuilder<CapacityContainerBuilder<Vec<(UpsertValue, T, Diff)>>>>;
269
270// The persist-feedback arrangement uses a `ValRowSpine<UpsertKey, _, _>`: keys
271// land in a columnation arena (`UpsertKey` is `[u8; 32]` + `Copy`, so it uses
272// `CopyRegion`), and values are stored as packed `Row` bytes in a
273// `DatumContainer`. `UpsertValue` is `Result<Row, Box<UpsertError>>`, so we
274// still need to fold both arms into a single `Row` with a leading tag column
275// so they share the value container.
276
277/// Encode an [`UpsertValue`] as a `Row` with a leading tag column so both `Ok`
278/// and `Err` payloads round-trip through `Row` byte storage.
279///
280/// Used on the render path. `pub` only so [`crate::fuzz_exports`] can re-export
281/// it under the `fuzzing` feature for the storage fuzz crate. The enclosing
282/// module is crate-private, so it is not otherwise reachable. Not a stable
283/// public API.
284#[doc(hidden)]
285pub fn upsert_value_to_row(value: &UpsertValue) -> Row {
286    let mut row = Row::default();
287    let mut packer = row.packer();
288    match value {
289        Ok(ok) => {
290            packer.push(Datum::UInt8(0));
291            packer.extend(ok.iter());
292        }
293        Err(err) => {
294            packer.push(Datum::UInt8(1));
295            let bytes =
296                bincode::serialize(err.as_ref()).expect("UpsertError is serializable via bincode");
297            packer.push(Datum::Bytes(&bytes));
298        }
299    }
300    row
301}
302
303/// Heap-size estimate for an emitted [`UpsertValue`], used to drive
304/// `give_fueled` yielding on the output edge.
305fn upsert_value_byte_len(value: &UpsertValue) -> usize {
306    match value {
307        Ok(row) => row.byte_len(),
308        Err(err) => std::mem::size_of_val(err.as_ref()),
309    }
310}
311
312/// Decode an [`UpsertValue`] produced by [`upsert_value_to_row`] back from the
313/// `DatumSeq` view returned by a `ValRowSpine` cursor.
314///
315/// Exists only for the storage fuzz crate, so it is gated behind the `fuzzing`
316/// feature. Not a stable public API.
317#[cfg(feature = "fuzzing")]
318#[doc(hidden)]
319pub fn datum_seq_to_upsert_value(seq: DatumSeq<'_>) -> UpsertValue {
320    decode_upsert_value(seq)
321}
322
323/// Decode an [`UpsertValue`] produced by [`upsert_value_to_row`] from any datum
324/// iterator — a `ValRowSpine` cursor's `DatumSeq` or a stashed `Row`'s `iter`.
325fn decode_upsert_value<'a>(mut iter: impl Iterator<Item = Datum<'a>>) -> UpsertValue {
326    let tag = match iter.next() {
327        Some(Datum::UInt8(tag)) => tag,
328        other => panic!("upsert value missing UInt8 tag, got {:?}", other),
329    };
330    match tag {
331        0 => {
332            let mut row = Row::default();
333            row.packer().extend(iter);
334            Ok(row)
335        }
336        1 => {
337            let bytes = match iter.next() {
338                Some(Datum::Bytes(b)) => b,
339                other => panic!("upsert error tag missing Bytes payload, got {:?}", other),
340            };
341            let err: UpsertError =
342                bincode::deserialize(bytes).expect("UpsertError bincode round-trip");
343            Err(Box::new(err))
344        }
345        tag => panic!("unknown upsert value tag {tag}"),
346    }
347}
348
349/// Transforms a stream of upserts (key-value updates) into a differential
350/// collection.
351///
352/// Persist feedback is arranged into a differential trace (DD manages the
353/// spine lifecycle). Source input is stashed with a custom `UpsertDiff`
354/// Semigroup that deduplicates by keeping the highest FromTime per (key, time).
355///
356/// Has two inputs:
357///   1. **Source input** — upsert commands from the external source.
358///   2. **Persist input** — feedback of the operator's own output, read back
359///      from persist.  Arranged into a trace for cursor-based lookups.
360#[allow(clippy::disallowed_methods)]
361pub fn upsert_inner<'scope, T, FromTime>(
362    input: VecCollection<'scope, T, (UpsertKey, Option<UpsertValue>, FromTime), Diff>,
363    key_indices: Vec<usize>,
364    resume_upper: Antichain<T>,
365    persist_input: VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
366    persist_token: Option<Vec<PressOnDropButton>>,
367    upsert_metrics: UpsertMetrics,
368    source_config: crate::source::SourceExportCreationConfig,
369) -> (
370    VecCollection<'scope, T, Result<Row, DataflowError>, Diff>,
371    StreamVec<'scope, T, (Option<GlobalId>, HealthStatusUpdate)>,
372    StreamVec<'scope, T, Infallible>,
373    PressOnDropButton,
374)
375where
376    T: Timestamp + TotalOrder + Sync,
377    T: Refines<mz_repr::Timestamp> + differential_dataflow::lattice::Lattice,
378    T: columnation::Columnation,
379    T: columnar::Columnar + Default,
380    for<'a> columnar::Ref<'a, T>: Copy + Ord,
381    FromTime: Debug + timely::ExchangeData + Clone + Ord + Sync,
382    FromTime: UpsertSourceTime,
383{
384    // Arrange persist feedback.
385    // Extract (UpsertKey, UpsertValue) from the persist feedback collection
386    // and arrange it. DD manages the spine, batching, and compaction.
387    let persist_keyed = persist_input.flat_map(move |result| {
388        let value = match result {
389            Ok(ok) => Ok(ok),
390            Err(DataflowError::EnvelopeError(err)) => match *err {
391                EnvelopeError::Upsert(err) => Err(Box::new(err)),
392                EnvelopeError::Flat(_) => return None,
393            },
394            Err(_) => return None,
395        };
396        let value_ref = match value {
397            Ok(ref row) => Ok(row),
398            Err(ref err) => Err(&**err),
399        };
400        Some((UpsertKey::from_value(value_ref, &key_indices), value))
401    });
402    let persist_keyed = persist_keyed
403        .inner
404        // The arrangement already implicitly exchanges by key, so this is redundant, but we want to
405        // do it earlier so that we can inspect the stream properly for source statistics.
406        .exchange(move |((key, _), _, _)| UpsertKey::hashed(key))
407        .as_collection()
408        .inspect(move |((_, row), _, diff)| {
409            source_config
410                .source_statistics
411                .update_records_indexed_by(diff.into_inner());
412            source_config.source_statistics.update_bytes_indexed_by(
413                row.as_ref().map_or(0, |r| r.byte_len().try_into().unwrap()) * diff.into_inner(),
414            );
415        });
416    // Encode (UpsertKey, UpsertValue) → (UpsertKey, Row) into `Column`
417    // containers so the feedback arrangement uses the paged columnar path: the
418    // batcher routes its spine input through the process-global pager, paging
419    // cold feedback chains out of RSS, while `ValRowSpine` keeps keys in a
420    // columnation arena (UpsertKey is fixed-size [u8; 32]) and values as packed
421    // `Row` bytes in a `DatumContainer`. Built with `Pipeline` so we keep the
422    // locality established by the `UpsertKey::hashed` exchange above.
423    let encoded = persist_keyed
424        .inner
425        .unary::<ColumnBuilder<((UpsertKey, Row), T, Diff)>, _, _, _>(
426            Pipeline,
427            "Persist feedback encode",
428            |_, _| {
429                move |input, output| {
430                    input.for_each(|time, data| {
431                        let mut session = output.session_with_builder(&time);
432                        for ((key, value), ts, diff) in data.drain(..) {
433                            let row = upsert_value_to_row(&value);
434                            session.give(((&key, &row), &ts, &diff));
435                        }
436                    });
437                }
438            },
439        );
440    let persist_arranged = arrange_core::<
441        _,
442        _,
443        ColumnChunker<((UpsertKey, Row), T, Diff)>,
444        UpsertFeedbackBatcher<T>,
445        ValRowColPagedBuilder<UpsertKey, T, Diff>,
446        ValRowSpine<UpsertKey, T, Diff>,
447    >(encoded, Pipeline, "Persist feedback");
448    let mut persist_trace = persist_arranged.trace.clone();
449
450    // Probe the persist arrangement's stream for frontier tracking.
451    // This replaces receiving the batch stream as an input — we just
452    // read the probe frontier to know when persist has caught up.
453    use timely::dataflow::operators::Probe;
454    let (persist_probe, _persist_probe_stream) = persist_arranged.stream.probe();
455
456    // Build the async processing operator.
457    let mut builder = AsyncOperatorBuilder::new("Upsert V2".to_string(), input.scope());
458
459    let (output_handle, output) = builder
460        .new_output::<FueledBuilder<CapacityContainerBuilder<Vec<(UpsertValue, T, Diff)>>>>();
461    let (_snapshot_handle, snapshot_stream) =
462        builder.new_output::<CapacityContainerBuilder<Vec<Infallible>>>();
463    let (_health_output, health_stream) = builder
464        .new_output::<CapacityContainerBuilder<Vec<(Option<GlobalId>, HealthStatusUpdate)>>>();
465
466    let mut input = builder.new_input_for(
467        input.inner,
468        Exchange::new(move |((key, _, _), _, _)| UpsertKey::hashed(key)),
469        &output_handle,
470    );
471
472    // We still need the persist stream as an input so the operator wakes
473    // when the persist arrangement produces batches (frontier advances).
474    // We read the actual frontier from the probe though.
475    let mut persist_wakeup = builder.new_disconnected_input(_persist_probe_stream, Pipeline);
476
477    let shutdown_button = builder.build(move |caps| async move {
478        // Hold the persist source tokens for the operator's lifetime so the
479        // feedback shard stays open until shutdown.
480        let _persist_token = persist_token;
481
482        let [output_cap, snapshot_cap, _health_cap]: [_; 3] = caps.try_into().unwrap();
483        drop(output_cap);
484        let mut snapshot_cap = CapabilitySet::from_elem(snapshot_cap);
485
486        let mut hydrating = true;
487
488        // Source stash backed by the paged columnar merge batcher. The batcher
489        // maintains geometrically-sized sorted chains and consolidates via the
490        // UpsertDiff Semigroup as data is pushed in, bounding memory to
491        // O(unique key-time pairs) even during large initial snapshots, and
492        // pages cold chains out of RSS through the pager.
493        //
494        // The pager is storage-owned (configured by `UpdateConfiguration` from
495        // storage's own dyncfgs), distinct from the compute column-paged
496        // batcher's process-global pager. Captured once here: backend / budget /
497        // codec tunes take effect live (the policy is reconfigured in place),
498        // but flipping the enable flag takes effect on dataflows created after
499        // the change. While disabled, the pager keeps every chunk resident.
500        let mut batcher: UpsertBatcher<T, FromTime::Order> = Batcher::new(None, 0);
501        batcher.set_pager(crate::upsert::upsert_stash_pager::pager());
502        // The chunker sorts and consolidates raw input into the `Column` chunks
503        // the batcher consumes.
504        let mut chunker: UpsertChunker<T, FromTime::Order> = Default::default();
505        // Scratch buffer for accumulating source events before flushing to
506        // the batcher. Drained on each iteration via the chunker.
507        let mut push_buffer: Vec<UpsertUpdate<T, FromTime::Order>> = Vec::new();
508
509        // Capability held at the minimum time of any buffered data. When
510        // Some, the operator may still produce output; when None, the
511        // batcher is empty.
512        let mut stash_cap: Option<Capability<T>> = None;
513        let mut input_upper = Antichain::from_elem(Timestamp::minimum());
514
515        let snapshot_start = std::time::Instant::now();
516        let mut prev_persist_upper = Antichain::from_elem(Timestamp::minimum());
517
518        // Accumulators for rehydration metrics, set as gauges when rehydration completes.
519        let mut rehydration_total: u64 = 0;
520        let mut rehydration_updates: u64 = 0;
521
522        // Main operator loop. Each iteration performs four steps:
523        //   Step 1: Ingest source data into the batcher.
524        //   Step 2: Read the persist frontier and update rehydration state.
525        //   Step 3: Seal the batcher, drain eligible entries, push back the rest.
526        //   Step 4: Manage the output capability.
527        loop {
528            // Block until woken by source input or a persist frontier advance.
529            tokio::select! {
530                _ = input.ready() => {}
531                _ = persist_wakeup.ready() => {
532                    while persist_wakeup.next_sync().is_some() {}
533                }
534            }
535
536            // Step 1: Ingest source data.
537            // Read all available source events, wrap each value in an
538            // UpsertDiff (carrying FromTime for dedup), and buffer them.
539            // Events before the resume_upper are dropped (already persisted).
540            while let Some(event) = input.next_sync() {
541                match event {
542                    AsyncEvent::Data(cap, data) => {
543                        let mut pushed_any = false;
544                        for ((key, value, from_time), ts, diff) in data {
545                            assert!(diff.is_positive(), "invalid upsert input");
546                            if PartialOrder::less_equal(&input_upper, &resume_upper)
547                                && !resume_upper.less_equal(&ts)
548                            {
549                                continue;
550                            }
551                            let value = value.as_ref().map(upsert_value_to_row);
552                            let from_time = from_time.upsert_order();
553                            push_buffer.push((key, ts, UpsertDiff { from_time, value }));
554                            pushed_any = true;
555                        }
556                        // Track the minimum capability across all buffered data
557                        // so we can emit output at the correct times.
558                        if pushed_any {
559                            stash_cap = Some(match stash_cap {
560                                Some(prev) if cap.time() < prev.time() => cap,
561                                Some(prev) => prev,
562                                None => cap,
563                            });
564                        }
565                    }
566                    AsyncEvent::Progress(upper) => {
567                        if PartialOrder::less_than(&upper, &resume_upper) {
568                            continue;
569                        }
570                        input_upper = upper;
571                    }
572                }
573            }
574
575            // Flush buffered events through the chunker into the batcher. This
576            // triggers the chunker + geometric chain merging, which consolidates
577            // entries for the same (key, time) via the UpsertDiff Semigroup.
578            flush_to_batcher(&mut push_buffer, &mut chunker, &mut batcher);
579
580            // Step 2: Read persist frontier.
581            // The persist probe tells us which output times have been
582            // committed back through the feedback loop. This determines:
583            //   - Whether rehydration is complete (persist >= resume_upper).
584            //   - Which source entries are eligible for processing (their
585            //     time must equal persist_upper so the trace cursor returns
586            //     the correct prior state).
587            //   - How far to compact the persist trace.
588            let persist_upper = persist_probe.with_frontier(|f| f.to_owned());
589
590            if persist_upper != prev_persist_upper {
591                let last_rehydration_chunk =
592                    hydrating && PartialOrder::less_equal(&resume_upper, &persist_upper);
593
594                if last_rehydration_chunk {
595                    hydrating = false;
596                    upsert_metrics
597                        .rehydration_latency
598                        .set(snapshot_start.elapsed().as_secs_f64());
599                    upsert_metrics.rehydration_total.set(rehydration_total);
600                    upsert_metrics.rehydration_updates.set(rehydration_updates);
601                    tracing::info!(
602                        worker_id = %source_config.worker_id,
603                        source_id = %source_config.id,
604                        "upsert finished rehydration",
605                    );
606                    snapshot_cap.downgrade(&[]);
607                }
608
609                let _ = snapshot_cap.try_downgrade(persist_upper.iter());
610
611                // Compact the trace so the spine can merge old batches.
612                persist_trace.set_logical_compaction(persist_upper.borrow());
613                persist_trace.set_physical_compaction(persist_upper.borrow());
614
615                prev_persist_upper = persist_upper.clone();
616            }
617
618            // Step 3: Seal & drain.
619            // Seal the batcher at input_upper to extract all source-finalized
620            // entries as sorted, consolidated chunks. The seal merges all
621            // internal chains (O(N) linear merge of sorted data) and splits
622            // by time: entries at ts < input_upper are extracted, the rest
623            // stay in the batcher.
624            //
625            // Extracted entries are partitioned into:
626            //   - Eligible (ts == persist_upper): processed now via cursor
627            //     lookup on the persist trace.
628            //   - Ineligible (persist_upper < ts < input_upper): persist
629            //     hasn't caught up yet; pushed back into the batcher.
630            //
631            // We skip the seal entirely unless an eligible entry is at all
632            // possible. `seal` performs an O(N) merge of all chains
633            // regardless of how much it extracts, so calling it when nothing
634            // can be processed makes the operator quadratic in the number of
635            // wakeups (a real pathology during upstream snapshots and during
636            // rehydration when the source races ahead of persist).
637            //
638            // For an entry at `ts` to be eligible we need
639            // `ts == persist_upper && ts < input_upper`. The necessary
640            // preconditions, expressible without scanning the batcher:
641            //   1. `cap.time() <= persist_upper`. Since `cap.time()` is
642            //      maintained as a lower bound on `min(ts in batcher)`, if
643            //      `cap.time() > persist_upper` then every buffered ts is
644            //      strictly above persist_upper and none can equal it.
645            //   2. `persist_upper < input_upper`. Otherwise no `ts` that
646            //      satisfies `ts == persist_upper` can also satisfy
647            //      `ts < input_upper`.
648            //
649            // This naturally covers both the post-hydration source-snapshot
650            // case (cap == persist == input → condition 2 fails) and the
651            // rehydration-with-source-ahead case (cap > persist → condition
652            // 1 fails). It also no-ops correctly when persist has shut down
653            // (empty persist_upper makes condition 2 vacuously false).
654            if let Some(cap) = stash_cap.as_mut()
655                && !persist_upper.less_than(cap.time())
656                && PartialOrder::less_than(&persist_upper, &input_upper)
657            {
658                // Step 1 already consolidated `push_buffer` through the chunker
659                // (which readies a complete chunk per `push_into`), so the
660                // chunker holds nothing pending here and we can seal directly.
661                let (sealed, _description) = batcher.seal(input_upper.clone());
662                // Frontier of data remaining in the batcher (ts >= input_upper).
663                let remaining_frontier = batcher.frontier().to_owned();
664
665                let mut ineligible = Vec::new();
666                // `drain_sealed_input` emits eligible output directly through
667                // `output_handle` (fueled), so there is no intermediate output
668                // buffer to drain afterward.
669                let drain_stats = drain_sealed_input(
670                    sealed,
671                    &mut ineligible,
672                    &output_handle,
673                    &*cap,
674                    &persist_upper,
675                    &mut persist_trace,
676                    &source_config.worker_id,
677                    &source_config.id,
678                )
679                .await;
680
681                upsert_metrics.multi_get_size.inc_by(drain_stats.eligible);
682                upsert_metrics
683                    .multi_get_result_count
684                    .inc_by(drain_stats.result_count);
685                upsert_metrics
686                    .multi_put_size
687                    .inc_by(drain_stats.output_count);
688                upsert_metrics.upsert_inserts.inc_by(drain_stats.inserts);
689                upsert_metrics.upsert_updates.inc_by(drain_stats.updates);
690                upsert_metrics.upsert_deletes.inc_by(drain_stats.deletes);
691
692                if hydrating {
693                    rehydration_total += drain_stats.inserts;
694                    rehydration_updates += drain_stats.eligible;
695                }
696
697                // Step 4: Capability management.
698                // Downgrade the output capability to the minimum time of any
699                // remaining data: either entries still in the batcher (above
700                // input_upper) or ineligible entries being pushed back.
701                let min_ineligible_ts = ineligible.iter().map(|(_, ts, _)| ts).min().cloned();
702                flush_to_batcher(&mut ineligible, &mut chunker, &mut batcher);
703
704                let has_remaining = !remaining_frontier.is_empty() || min_ineligible_ts.is_some();
705                if has_remaining {
706                    let min_ts = match (
707                        remaining_frontier.elements().first(),
708                        min_ineligible_ts.as_ref(),
709                    ) {
710                        (Some(a), Some(b)) => std::cmp::min(a, b).clone(),
711                        (Some(a), None) => a.clone(),
712                        (None, Some(b)) => b.clone(),
713                        (None, None) => unreachable!(),
714                    };
715                    cap.downgrade(&min_ts);
716                } else {
717                    // Batcher is completely empty — drop the capability so
718                    // downstream operators can make progress.
719                    stash_cap = None;
720                }
721            }
722
723            if input_upper.is_empty() {
724                break;
725            }
726        }
727    });
728
729    (
730        output
731            .as_collection()
732            .map(|result: UpsertValue| match result {
733                Ok(ok) => Ok(ok),
734                Err(err) => Err(DataflowError::from(EnvelopeError::Upsert(*err))),
735            }),
736        health_stream,
737        snapshot_stream,
738        shutdown_button.press_on_drop(),
739    )
740}
741
742/// Counts from a single call to [`drain_sealed_input`], used to update metrics.
743struct DrainStats {
744    /// Number of entries looked up in the persist trace (cursor seeks).
745    eligible: u64,
746    /// Number of cursor lookups that found an existing value.
747    result_count: u64,
748    /// New value written with no prior value (insert).
749    inserts: u64,
750    /// New value written over an existing value (update).
751    updates: u64,
752    /// Tombstone (None) applied to an existing value (delete).
753    deletes: u64,
754    /// Total output records emitted (retractions + insertions).
755    output_count: u64,
756}
757
758/// Process sealed chunks from the batcher, classifying each entry by its
759/// timestamp relative to `persist_upper`: entries at the frontier are eligible
760/// for processing now (cursor lookup + output), entries above it are returned
761/// in `ineligible` for re-stashing, and entries below it are already persisted
762/// and dropped (see the body for why).
763///
764/// The sealed chunks are already sorted and consolidated by the MergeBatcher,
765/// so the trace cursor walks forward through keys in order — seeks amortize.
766async fn drain_sealed_input<T, O>(
767    sealed: Vec<Column<UpsertUpdate<T, O>>>,
768    ineligible: &mut Vec<UpsertUpdate<T, O>>,
769    output_handle: &UpsertOutputHandle<T>,
770    output_cap: &Capability<T>,
771    persist_upper: &Antichain<T>,
772    trace: &mut TraceAgent<ValRowSpine<UpsertKey, T, Diff>>,
773    worker_id: &usize,
774    source_id: &GlobalId,
775) -> DrainStats
776where
777    T: TotalOrder + Lattice + timely::ExchangeData + Timestamp + Clone + Debug + Ord + Sync,
778    T: columnation::Columnation + columnar::Columnar,
779    O: columnar::Columnar,
780{
781    // Classify each entry by its timestamp relative to `persist_upper`:
782    //
783    //   * `ts == persist_upper`: eligible for processing now.
784    //   * `ts >  persist_upper`: not yet processable; re-stashed (ineligible)
785    //     until the feedback frontier catches up to it.
786    //   * `ts <  persist_upper`: already persisted by some writer and not
787    //     relevant anymore. We DROP it. The downstream persist_sink would
788    //     filter such updates out anyway since the shard upper is further
789    //     ahead, and our state is already up-to-date to `persist_upper` so we
790    //     could not emit correct retractions for it. Re-stashing it would
791    //     strand the data forever (`persist_upper` only advances, so
792    //     `ts == persist_upper` can never again hold) and pin the operator's
793    //     output frontier below the shard upper. This mirrors v1's
794    //     `relevant = persist_upper.less_equal(ts)`.
795    // Walk the sealed chunks by reference rather than collecting the eligible
796    // set into an owned Vec. The chunks are globally sorted (the seal merges
797    // all chains into one run), so the cursor seeks still walk forward and
798    // amortize, and eligible values are emitted straight from the column's
799    // `RowRef` with no owned `UpsertDiff` copy. Only the re-stashed ineligible
800    // set is materialized.
801    let mut eligible_count: u64 = 0;
802    let mut result_count: u64 = 0;
803    let mut output_count: u64 = 0;
804    let mut inserts: u64 = 0;
805    let mut updates: u64 = 0;
806    let mut deletes: u64 = 0;
807
808    let (mut cursor, storage) = trace.cursor();
809
810    for chunk in &sealed {
811        for (key, ts, diff) in chunk.borrow().into_index_iter() {
812            let ts = <T as columnar::Columnar>::into_owned(ts);
813            if !persist_upper.less_equal(&ts) {
814                // ts < persist_upper: drop.
815                continue;
816            }
817            if persist_upper.less_than(&ts) {
818                // ts > persist_upper: re-stash for later (owned).
819                ineligible.push((
820                    *key,
821                    ts,
822                    <UpsertDiff<O> as columnar::Columnar>::into_owned(diff),
823                ));
824                continue;
825            }
826
827            // ts == persist_upper: eligible. Look up the prior value for this
828            // key in the persist trace and emit the retraction / insertion. The
829            // spine stores keys in a columnation arena, so we seek by the
830            // column's borrowed `&UpsertKey` directly.
831            eligible_count += 1;
832            cursor.seek_key(&storage, key);
833            let old_value = match cursor.get_key(&storage) {
834                Some(found) if found == key => {
835                    let mut result = None;
836                    while let Some(val) = cursor.get_val(&storage) {
837                        let mut count = Diff::ZERO;
838                        cursor.map_times(&storage, |_time, d| {
839                            count += d.clone();
840                        });
841                        if count.is_positive() {
842                            assert!(
843                                count == 1.into(),
844                                "unexpected multiple entries for the same key in persist trace"
845                            );
846                            assert!(
847                                result.is_none(),
848                                "unexpected multiple values for the same key in persist trace"
849                            );
850                            result = Some(decode_upsert_value(val));
851                        }
852                        cursor.step_val(&storage);
853                    }
854                    result
855                }
856                _ => None,
857            };
858
859            if old_value.is_some() {
860                result_count += 1;
861            }
862
863            match diff.value {
864                Some(row) => {
865                    if let Some(old_val) = old_value {
866                        let size = upsert_value_byte_len(&old_val);
867                        output_handle
868                            .give_fueled(output_cap, (old_val, ts.clone(), Diff::MINUS_ONE), size)
869                            .await;
870                        output_count += 1;
871                        updates += 1;
872                    } else {
873                        inserts += 1;
874                    }
875                    let new_val = decode_upsert_value(row.iter());
876                    let size = upsert_value_byte_len(&new_val);
877                    output_handle
878                        .give_fueled(output_cap, (new_val, ts, Diff::ONE), size)
879                        .await;
880                    output_count += 1;
881                }
882                None => {
883                    if let Some(old_val) = old_value {
884                        let size = upsert_value_byte_len(&old_val);
885                        output_handle
886                            .give_fueled(output_cap, (old_val, ts, Diff::MINUS_ONE), size)
887                            .await;
888                        output_count += 1;
889                        deletes += 1;
890                    }
891                }
892            }
893        }
894    }
895
896    tracing::debug!(
897        worker_id = %worker_id,
898        source_id = %source_id,
899        ineligible = ineligible.len(),
900        eligible = eligible_count,
901        "drained stash",
902    );
903
904    DrainStats {
905        eligible: eligible_count,
906        result_count,
907        inserts,
908        updates,
909        deletes,
910        output_count,
911    }
912}
913
914#[cfg(test)]
915mod test {
916    use mz_ore::metrics::MetricsRegistry;
917    use mz_persist_types::ShardId;
918    use mz_repr::{Datum, Timestamp as MzTimestamp};
919    use mz_storage_operators::persist_source::Subtime;
920    use mz_storage_types::sources::SourceEnvelope;
921    use mz_storage_types::sources::envelope::{KeyEnvelope, UpsertEnvelope, UpsertStyle};
922    use timely::dataflow::operators::capture::Extract;
923    use timely::dataflow::operators::{Capture, Input};
924    use timely::progress::Timestamp;
925
926    use crate::metrics::StorageMetrics;
927    use crate::metrics::upsert::UpsertMetricDefs;
928    use crate::source::SourceExportCreationConfig;
929    use crate::statistics::{SourceStatistics, SourceStatisticsMetricDefs};
930
931    use super::*;
932
933    // The tests drive the operator with a plain integer `FromTime` standing in
934    // for a Kafka offset; project it to itself so dedup orders by it directly.
935    impl UpsertSourceTime for i32 {
936        type Order = i32;
937        fn upsert_order(&self) -> i32 {
938            *self
939        }
940    }
941
942    type Ts = (MzTimestamp, Subtime);
943
944    fn new_ts(ts: u64) -> Ts {
945        (MzTimestamp::new(ts), Subtime::minimum())
946    }
947
948    fn key(k: i64) -> UpsertKey {
949        UpsertKey::from_key(Ok(&Row::pack_slice(&[Datum::Int64(k)])))
950    }
951
952    fn row(k: i64, v: i64) -> Row {
953        Row::pack_slice(&[Datum::Int64(k), Datum::Int64(v)])
954    }
955
956    macro_rules! upsert_test {
957        (|$input:ident, $persist:ident, $worker:ident| $body:block) => {{
958            let output_handle = timely::execute_directly(move |$worker| {
959                let (mut $input, mut $persist, output_handle) = $worker
960                    .dataflow::<MzTimestamp, _, _>(|scope| {
961                        scope.scoped::<Ts, _, _>("upsert", |scope| {
962                            let (input_handle, input) = scope.new_input();
963                            let (persist_handle, persist_input) = scope.new_input();
964                            let source_id = GlobalId::User(0);
965
966                            let reg = MetricsRegistry::new();
967                            let upsert_defs = UpsertMetricDefs::register_with(&reg);
968                            let upsert_metrics =
969                                UpsertMetrics::new(&upsert_defs, source_id, 0, None);
970
971                            let reg2 = MetricsRegistry::new();
972                            let storage_metrics = StorageMetrics::register_with(&reg2);
973
974                            let reg3 = MetricsRegistry::new();
975                            let stats_defs =
976                                SourceStatisticsMetricDefs::register_with(&reg3);
977                            let envelope = SourceEnvelope::Upsert(UpsertEnvelope {
978                                source_arity: 2,
979                                style: UpsertStyle::Default(KeyEnvelope::Flattened),
980                                key_indices: vec![0],
981                            });
982                            let source_statistics = SourceStatistics::new(
983                                source_id, 0, &stats_defs, source_id, &ShardId::new(),
984                                envelope, Antichain::from_elem(Timestamp::minimum()),
985                            );
986                            let source_config = SourceExportCreationConfig {
987                                id: source_id,
988                                worker_id: 0,
989                                metrics: storage_metrics,
990                                source_statistics,
991                            };
992
993                            let (output, _, _, button) = upsert_inner(
994                                input.as_collection(),
995                                vec![0],
996                                Antichain::from_elem(Timestamp::minimum()),
997                                persist_input.as_collection(),
998                                None,
999                                upsert_metrics,
1000                                source_config,
1001                            );
1002                            std::mem::forget(button);
1003                            (input_handle, persist_handle, output.inner.capture())
1004                        })
1005                    });
1006
1007                $body
1008
1009                output_handle
1010            });
1011
1012            let mut actual: Vec<_> = output_handle
1013                .extract()
1014                .into_iter()
1015                .flat_map(|(_cap, container)| container)
1016                .collect();
1017            differential_dataflow::consolidation::consolidate_updates(&mut actual);
1018            actual
1019        }};
1020    }
1021
1022    #[mz_ore::test]
1023    #[cfg_attr(miri, ignore)]
1024    fn gh_9160_repro() {
1025        let actual = upsert_test!(|input, persist, worker| {
1026            let key0 = key(0);
1027            let key1 = key(1);
1028            let value1 = row(0, 0);
1029            let value3 = row(0, 1);
1030            let value4 = row(0, 2);
1031
1032            input.send(((key0, Some(Ok(value1.clone())), 1), new_ts(0), Diff::ONE));
1033            input.advance_to(new_ts(2));
1034            worker.step();
1035
1036            persist.send((Ok(value1), new_ts(0), Diff::ONE));
1037            persist.advance_to(new_ts(1));
1038            worker.step();
1039
1040            input.send_batch(&mut vec![
1041                ((key1, None, 2), new_ts(2), Diff::ONE),
1042                ((key0, Some(Ok(value3)), 3), new_ts(3), Diff::ONE),
1043            ]);
1044            input.advance_to(new_ts(3));
1045            input.send_batch(&mut vec![(
1046                (key0, Some(Ok(value4)), 4),
1047                new_ts(3),
1048                Diff::ONE,
1049            )]);
1050            input.advance_to(new_ts(4));
1051            worker.step();
1052
1053            persist.advance_to(new_ts(3));
1054            worker.step();
1055        });
1056
1057        let value1 = row(0, 0);
1058        let value4 = row(0, 2);
1059        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1060            (Ok(value1.clone()), new_ts(0), Diff::ONE),
1061            (Ok(value1), new_ts(3), Diff::MINUS_ONE),
1062            (Ok(value4), new_ts(3), Diff::ONE),
1063        ];
1064        assert_eq!(actual, expected);
1065    }
1066
1067    #[mz_ore::test]
1068    #[cfg_attr(miri, ignore)]
1069    fn out_of_order_keys_across_timestamps() {
1070        let actual = upsert_test!(|input, persist, worker| {
1071            let key_high = key(99);
1072            let key_low = key(1);
1073            let val_a = row(99, 1);
1074            let val_b = row(1, 2);
1075
1076            input.send(((key_high, Some(Ok(val_a.clone())), 1), new_ts(0), Diff::ONE));
1077            input.advance_to(new_ts(1));
1078            worker.step();
1079            persist.send((Ok(val_a.clone()), new_ts(0), Diff::ONE));
1080            persist.advance_to(new_ts(1));
1081            worker.step();
1082
1083            input.send(((key_low, Some(Ok(val_b.clone())), 2), new_ts(1), Diff::ONE));
1084            input.advance_to(new_ts(2));
1085            worker.step();
1086            persist.send((Ok(val_b.clone()), new_ts(1), Diff::ONE));
1087            persist.advance_to(new_ts(2));
1088            worker.step();
1089
1090            let val_a2 = row(99, 10);
1091            let val_b2 = row(1, 20);
1092            input.send_batch(&mut vec![
1093                (
1094                    (key_high, Some(Ok(val_a2.clone())), 3),
1095                    new_ts(2),
1096                    Diff::ONE,
1097                ),
1098                ((key_low, Some(Ok(val_b2.clone())), 4), new_ts(2), Diff::ONE),
1099            ]);
1100            input.advance_to(new_ts(3));
1101            worker.step();
1102            persist.advance_to(new_ts(3));
1103            worker.step();
1104        });
1105
1106        let val_a = row(99, 1);
1107        let val_b = row(1, 2);
1108        let val_a2 = row(99, 10);
1109        let val_b2 = row(1, 20);
1110        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1111            (Ok(val_b.clone()), new_ts(1), Diff::ONE),
1112            (Ok(val_b), new_ts(2), Diff::MINUS_ONE),
1113            (Ok(val_b2), new_ts(2), Diff::ONE),
1114            (Ok(val_a.clone()), new_ts(0), Diff::ONE),
1115            (Ok(val_a), new_ts(2), Diff::MINUS_ONE),
1116            (Ok(val_a2), new_ts(2), Diff::ONE),
1117        ];
1118        let mut actual_sorted = actual;
1119        let mut expected_sorted = expected;
1120        actual_sorted.sort();
1121        expected_sorted.sort();
1122        assert_eq!(actual_sorted, expected_sorted);
1123    }
1124
1125    #[mz_ore::test]
1126    #[cfg_attr(miri, ignore)]
1127    fn rehydration_then_update() {
1128        let actual = upsert_test!(|input, persist, worker| {
1129            let k = key(42);
1130            let old_val = row(42, 100);
1131            let new_val = row(42, 200);
1132
1133            persist.send((Ok(old_val), new_ts(0), Diff::ONE));
1134            persist.advance_to(new_ts(1));
1135            worker.step();
1136
1137            input.send(((k, Some(Ok(new_val)), 1), new_ts(1), Diff::ONE));
1138            input.advance_to(new_ts(2));
1139            worker.step();
1140            persist.advance_to(new_ts(2));
1141            worker.step();
1142        });
1143
1144        let old_val = row(42, 100);
1145        let new_val = row(42, 200);
1146        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1147            (Ok(old_val), new_ts(1), Diff::MINUS_ONE),
1148            (Ok(new_val), new_ts(1), Diff::ONE),
1149        ];
1150        assert_eq!(actual, expected);
1151    }
1152
1153    #[mz_ore::test]
1154    #[cfg_attr(miri, ignore)]
1155    fn delete_existing_key() {
1156        let actual = upsert_test!(|input, persist, worker| {
1157            let k = key(7);
1158            let val = row(7, 77);
1159
1160            input.send(((k, Some(Ok(val.clone())), 1), new_ts(0), Diff::ONE));
1161            input.advance_to(new_ts(1));
1162            worker.step();
1163            persist.send((Ok(val), new_ts(0), Diff::ONE));
1164            persist.advance_to(new_ts(1));
1165            worker.step();
1166
1167            input.send(((k, None, 2), new_ts(1), Diff::ONE));
1168            input.advance_to(new_ts(2));
1169            worker.step();
1170            persist.advance_to(new_ts(2));
1171            worker.step();
1172        });
1173
1174        let val = row(7, 77);
1175        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1176            (Ok(val.clone()), new_ts(0), Diff::ONE),
1177            (Ok(val), new_ts(1), Diff::MINUS_ONE),
1178        ];
1179        assert_eq!(actual, expected);
1180    }
1181
1182    #[mz_ore::test]
1183    #[cfg_attr(miri, ignore)]
1184    fn multi_batch_rehydration() {
1185        let actual = upsert_test!(|input, persist, worker| {
1186            let k = key(5);
1187            let old_val = row(5, 10);
1188            let new_val = row(5, 20);
1189            let updated_val = row(5, 30);
1190
1191            persist.send((Ok(old_val.clone()), new_ts(0), Diff::ONE));
1192            persist.send((Ok(old_val), new_ts(0), Diff::MINUS_ONE));
1193            persist.send((Ok(new_val), new_ts(0), Diff::ONE));
1194            persist.advance_to(new_ts(1));
1195            worker.step();
1196
1197            input.send(((k, Some(Ok(updated_val)), 1), new_ts(1), Diff::ONE));
1198            input.advance_to(new_ts(2));
1199            worker.step();
1200            persist.advance_to(new_ts(2));
1201            worker.step();
1202        });
1203
1204        let new_val = row(5, 20);
1205        let updated_val = row(5, 30);
1206        let expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1207            (Ok(new_val), new_ts(1), Diff::MINUS_ONE),
1208            (Ok(updated_val), new_ts(1), Diff::ONE),
1209        ];
1210        assert_eq!(actual, expected);
1211    }
1212
1213    #[mz_ore::test]
1214    #[cfg_attr(miri, ignore)]
1215    fn delete_nonexistent_key() {
1216        let actual = upsert_test!(|input, persist, worker| {
1217            let k = key(99);
1218
1219            persist.advance_to(new_ts(1));
1220            worker.step();
1221
1222            input.send(((k, None, 1), new_ts(1), Diff::ONE));
1223            input.advance_to(new_ts(2));
1224            worker.step();
1225            persist.advance_to(new_ts(2));
1226            worker.step();
1227        });
1228
1229        assert!(actual.is_empty(), "expected empty output, got: {actual:?}");
1230    }
1231
1232    #[mz_ore::test]
1233    #[cfg_attr(miri, ignore)]
1234    fn reinsert_after_delete() {
1235        let actual = upsert_test!(|input, persist, worker| {
1236            let k = key(3);
1237            let val_a = row(3, 10);
1238            let val_b = row(3, 20);
1239
1240            input.send(((k, Some(Ok(val_a.clone())), 1), new_ts(0), Diff::ONE));
1241            input.advance_to(new_ts(1));
1242            worker.step();
1243            persist.send((Ok(val_a.clone()), new_ts(0), Diff::ONE));
1244            persist.advance_to(new_ts(1));
1245            worker.step();
1246
1247            input.send(((k, None, 2), new_ts(1), Diff::ONE));
1248            input.advance_to(new_ts(2));
1249            worker.step();
1250            persist.send((Ok(val_a), new_ts(1), Diff::MINUS_ONE));
1251            persist.advance_to(new_ts(2));
1252            worker.step();
1253
1254            input.send(((k, Some(Ok(val_b.clone())), 3), new_ts(2), Diff::ONE));
1255            input.advance_to(new_ts(3));
1256            worker.step();
1257            persist.advance_to(new_ts(3));
1258            worker.step();
1259        });
1260
1261        let val_a = row(3, 10);
1262        let val_b = row(3, 20);
1263        let mut expected: Vec<(Result<Row, DataflowError>, _, _)> = vec![
1264            (Ok(val_a.clone()), new_ts(0), Diff::ONE),
1265            (Ok(val_a), new_ts(1), Diff::MINUS_ONE),
1266            (Ok(val_b), new_ts(2), Diff::ONE),
1267        ];
1268        expected.sort();
1269        let mut actual = actual;
1270        actual.sort();
1271        assert_eq!(actual, expected);
1272    }
1273
1274    #[mz_ore::test]
1275    #[cfg_attr(miri, ignore)]
1276    fn idempotent_update() {
1277        let actual = upsert_test!(|input, persist, worker| {
1278            let k = key(11);
1279            let val = row(11, 50);
1280
1281            input.send(((k, Some(Ok(val.clone())), 1), new_ts(0), Diff::ONE));
1282            input.advance_to(new_ts(1));
1283            worker.step();
1284            persist.send((Ok(val.clone()), new_ts(0), Diff::ONE));
1285            persist.advance_to(new_ts(1));
1286            worker.step();
1287
1288            input.send(((k, Some(Ok(val.clone())), 2), new_ts(1), Diff::ONE));
1289            input.advance_to(new_ts(2));
1290            worker.step();
1291            persist.advance_to(new_ts(2));
1292            worker.step();
1293        });
1294
1295        let val = row(11, 50);
1296        let expected: Vec<(Result<Row, DataflowError>, _, _)> =
1297            vec![(Ok(val), new_ts(0), Diff::ONE)];
1298        assert_eq!(actual, expected);
1299    }
1300
1301    /// Operator-level repro of the 0dt read-only-handoff stranding bug.
1302    ///
1303    /// Models a lagging replacement generation: the external (old) writer has
1304    /// already advanced the shard — and therefore the feedback `persist_upper`
1305    /// — to `T = 10`, while the operator itself has emitted nothing. The
1306    /// lagging replacement now produces source data at timestamps BELOW that
1307    /// upper (`ts = 5, 7`), i.e. data the external writer has already persisted.
1308    ///
1309    /// `drain_sealed_input` DROPS such already-persisted data (it satisfies
1310    /// neither `ts == persist_upper` nor `ts > persist_upper`), mirroring v1's
1311    /// `relevant = persist_upper.less_equal(ts)`. Were it instead re-stashed,
1312    /// the data would be stranded forever — `persist_upper` only advances, so
1313    /// `ts == persist_upper` could never again hold — and `min_ineligible_ts`
1314    /// would pin the operator's output capability at `ts = 5`, BELOW the shard
1315    /// upper, where it would stay for good. Dropping it lets the frontier
1316    /// advance freely.
1317    #[mz_ore::test]
1318    #[cfg_attr(miri, ignore)]
1319    fn lagging_replacement_below_upper_strands_data() {
1320        let (frontier, emitted) = run_below_upper_scenario_v2();
1321
1322        // The below-upper data is discarded (no output) and the output frontier
1323        // is not pinned below the shard upper (10); it advances to the input
1324        // upper (11), matching v1's behavior.
1325        assert!(
1326            emitted.is_empty(),
1327            "below-upper data should be dropped, not emitted; got {emitted:?}"
1328        );
1329        assert_eq!(
1330            frontier,
1331            vec![new_ts(11)],
1332            "v2 output frontier should advance to the input upper, not pin below \
1333             persist_upper"
1334        );
1335        assert!(
1336            frontier[0] >= new_ts(10),
1337            "v2 output frontier {frontier:?} should reach at least persist_upper (10)"
1338        );
1339    }
1340
1341    /// Shared driver for the lagging-replacement scenario against v2. Returns
1342    /// `(output_frontier, consolidated_emitted_updates)`.
1343    fn run_below_upper_scenario_v2() -> (Vec<Ts>, Vec<(Result<Row, DataflowError>, Ts, Diff)>) {
1344        use timely::dataflow::operators::Probe;
1345
1346        let (frontier, capture) = timely::execute_directly(move |worker| {
1347            let (mut input, mut persist, probe, capture) =
1348                worker.dataflow::<MzTimestamp, _, _>(|scope| {
1349                    scope.scoped::<Ts, _, _>("upsert", |scope| {
1350                        let (input_handle, input) = scope.new_input();
1351                        let (persist_handle, persist_input) = scope.new_input();
1352                        let source_id = GlobalId::User(0);
1353
1354                        let reg = MetricsRegistry::new();
1355                        let upsert_defs = UpsertMetricDefs::register_with(&reg);
1356                        let upsert_metrics = UpsertMetrics::new(&upsert_defs, source_id, 0, None);
1357
1358                        let reg2 = MetricsRegistry::new();
1359                        let storage_metrics = StorageMetrics::register_with(&reg2);
1360
1361                        let reg3 = MetricsRegistry::new();
1362                        let stats_defs = SourceStatisticsMetricDefs::register_with(&reg3);
1363                        let envelope = SourceEnvelope::Upsert(UpsertEnvelope {
1364                            source_arity: 2,
1365                            style: UpsertStyle::Default(KeyEnvelope::Flattened),
1366                            key_indices: vec![0],
1367                        });
1368                        let source_statistics = SourceStatistics::new(
1369                            source_id,
1370                            0,
1371                            &stats_defs,
1372                            source_id,
1373                            &ShardId::new(),
1374                            envelope,
1375                            Antichain::from_elem(Timestamp::minimum()),
1376                        );
1377                        let source_config = SourceExportCreationConfig {
1378                            id: source_id,
1379                            worker_id: 0,
1380                            metrics: storage_metrics,
1381                            source_statistics,
1382                        };
1383
1384                        let (output, _, _, button) = upsert_inner(
1385                            input.as_collection(),
1386                            vec![0],
1387                            Antichain::from_elem(Timestamp::minimum()),
1388                            persist_input.as_collection(),
1389                            None,
1390                            upsert_metrics,
1391                            source_config,
1392                        );
1393                        std::mem::forget(button);
1394                        let (probe, stream) = output.inner.probe();
1395                        (input_handle, persist_handle, probe, stream.capture())
1396                    })
1397                });
1398
1399            // The external writer has advanced the shard (feedback persist_upper)
1400            // to T = 10 WITHOUT the operator emitting anything itself.
1401            persist.advance_to(new_ts(10));
1402            for _ in 0..20 {
1403                worker.step();
1404            }
1405
1406            // The lagging replacement produces source data at ts BELOW the
1407            // current persist_upper (5 and 7 while persist_upper = 10).
1408            input.send(((key(0), Some(Ok(row(0, 1))), 1), new_ts(5), Diff::ONE));
1409            input.send(((key(1), Some(Ok(row(1, 2))), 2), new_ts(7), Diff::ONE));
1410            input.advance_to(new_ts(11));
1411            for _ in 0..20 {
1412                worker.step();
1413            }
1414
1415            (probe.with_frontier(|f| f.to_vec()), capture)
1416        });
1417
1418        let mut emitted: Vec<_> = capture
1419            .extract()
1420            .into_iter()
1421            .flat_map(|(_cap, c)| c)
1422            .collect();
1423        differential_dataflow::consolidation::consolidate_updates(&mut emitted);
1424        (frontier, emitted)
1425    }
1426}