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