Skip to main content

mz_storage/upsert_continual_feedback_v2/
drain.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//! Draining sealed stash chunks against the persist feedback, one function per
11//! stash flavor.
12
13use differential_dataflow::lattice::Lattice;
14use differential_dataflow::operators::arrange::agent::TraceAgent;
15use differential_dataflow::trace::cursor::cursor_list;
16use differential_dataflow::trace::{Cursor, TraceReader};
17use mz_repr::{Diff, GlobalId};
18use mz_row_spine::FundedValRowSpine;
19use mz_timely_util::columnar::Column;
20use mz_timely_util::columnar::body::ColumnBody;
21use mz_timely_util::columnar::unload::UnloadBatch;
22use timely::dataflow::operators::Capability;
23use timely::order::{PartialOrder, TotalOrder};
24use timely::progress::{Antichain, Timestamp};
25
26use super::UpsertOutputHandle;
27use super::feedback::{FeedbackSpine, FeedbackUpdate, decode_upsert_value, upsert_value_byte_len};
28use super::stash::{TimeMajorUpdate, UpsertDiff, UpsertUpdate};
29use crate::upsert::{UpsertKey, UpsertValue};
30
31/// Where a stashed entry's time falls relative to the feedback frontier.
32enum TimeClass {
33    /// `ts < persist_upper`: already persisted, dropped by the drain.
34    AlreadyPersisted,
35    /// `ts == persist_upper`: processed now.
36    Eligible,
37    /// `ts > persist_upper`: re-stashed until persist catches up.
38    Ineligible,
39}
40
41/// Classify `ts` against `persist_upper`: the single spelling of the drain's
42/// eligibility test. The chunked drain's probe-collection pass and its
43/// classification pass, and the paged drain's cursor walk, all call this, so
44/// the probe set and the classification cannot disagree. Under the
45/// operator's total order, `Eligible` means `ts` equals the frontier's one
46/// element.
47fn classify_time<T: PartialOrder>(persist_upper: &Antichain<T>, ts: &T) -> TimeClass {
48    if !persist_upper.less_equal(ts) {
49        TimeClass::AlreadyPersisted
50    } else if persist_upper.less_than(ts) {
51        TimeClass::Ineligible
52    } else {
53        TimeClass::Eligible
54    }
55}
56
57/// Counts from a single call to [`drain_sealed_input_chunked`] or
58/// [`drain_sealed_input_paged`], used to update metrics.
59pub(super) struct DrainStats {
60    /// Number of eligible entries probed against the feedback trace.
61    pub(super) eligible: u64,
62    /// Number of probed entries for which a prior value was found.
63    pub(super) result_count: u64,
64    /// New value written with no prior value (insert).
65    pub(super) inserts: u64,
66    /// New value written over an existing value (update).
67    pub(super) updates: u64,
68    /// Tombstone (None) applied to an existing value (delete).
69    pub(super) deletes: u64,
70    /// Total output records emitted (retractions + insertions).
71    pub(super) output_count: u64,
72}
73
74/// Process sealed chunks from the batcher, classifying each entry by its
75/// timestamp relative to `persist_upper`:
76///
77///   * `ts == persist_upper`: eligible for processing now (bulk probe of the
78///     feedback trace + output).
79///   * `ts >  persist_upper`: not yet processable. Returned in `ineligible`
80///     for re-stashing until the feedback frontier catches up to it.
81///   * `ts <  persist_upper`: already persisted by some writer and not
82///     relevant anymore, so DROPPED. The downstream persist_sink would filter
83///     such updates out anyway since the shard upper is further ahead, and
84///     our state is already up-to-date to `persist_upper` so we could not
85///     emit correct retractions for it. Re-stashing it would strand the data
86///     forever (`persist_upper` only advances, so `ts == persist_upper` can
87///     never again hold) and pin the operator's output frontier below the
88///     shard upper. This mirrors v1's `relevant = persist_upper.less_equal(ts)`.
89///
90/// The sealed chunks are already sorted and consolidated by the merge
91/// batcher, so each chunk's eligible keys form a sorted, deduplicated probe
92/// set, and the prior state comes back through one bulk `extract_into` pass
93/// per trace batch. Resident fence metadata selects the touched trace
94/// chunks, and only those bodies are read back, copy-out, per chunk probed.
95/// Sealed chunks are pulled from the iterator one at a time and dropped
96/// before the next is requested, so at most one loaded stash chunk (plus the
97/// probe hits for its keys) is resident regardless of drain size. Only the
98/// re-stashed ineligible set is materialized.
99pub(super) async fn drain_sealed_input_chunked<T, O>(
100    sealed: impl Iterator<Item = ColumnBody<TimeMajorUpdate<T, O>>>,
101    ineligible: &mut Vec<UpsertUpdate<T, O>>,
102    output_handle: &UpsertOutputHandle<T>,
103    output_cap: &Capability<T>,
104    persist_upper: &Antichain<T>,
105    trace: &mut TraceAgent<FeedbackSpine<T>>,
106    worker_id: usize,
107    source_id: GlobalId,
108) -> DrainStats
109where
110    T: Timestamp + TotalOrder + Lattice + Sync,
111    T: columnation::Columnation + mz_timely_util::columnar::chunk::PoolColumnar + Default,
112    for<'a> columnar::Ref<'a, T>: Copy + Ord,
113    O: mz_timely_util::columnar::chunk::PoolColumnar,
114{
115    let mut eligible_count: u64 = 0;
116    let mut result_count: u64 = 0;
117    let mut output_count: u64 = 0;
118    let mut inserts: u64 = 0;
119    let mut updates: u64 = 0;
120    let mut deletes: u64 = 0;
121
122    // The batches this drain reads against. `Rc` clones of the trace's
123    // sealed chunk batches: chunk bodies stay spilled and are read back
124    // copy-out, per probed chunk, inside `extract_into`.
125    let batches = trace
126        .batches_through(Antichain::new().borrow())
127        .expect("complete batch set for the feedback trace; is it closed?");
128
129    // Eligible keys are probed against the trace in windows of this many
130    // distinct keys, bounding what one pass stages resident: probe hits carry
131    // full values, so an unwindowed pass over a byte-graded chunk of small
132    // records could stage tens of thousands of values at once.
133    const PROBE_WINDOW: usize = 1024;
134
135    for chunk in sealed {
136        use columnar::{Index, Len};
137        let view = chunk.borrow();
138        let total = view.len();
139        let mut start = 0;
140        while start < total {
141            // Pass 1: this window's sorted, deduplicated probe keys. The
142            // chunk is sorted by (time, key) and eligible entries share one
143            // time, so they are contiguous and key-sorted: a window is a
144            // contiguous record range, probes come out sorted, and dedup is
145            // a neighbor test. The window closes where its PROBE_WINDOW + 1st
146            // distinct eligible key would begin.
147            let mut probe_col = <UpsertKey as columnar::Columnar>::Container::default();
148            let mut probe_count = 0usize;
149            let mut end = total;
150            {
151                use columnar::Push;
152                let mut last_probe: Option<&UpsertKey> = None;
153                for index in start..total {
154                    let ((_, key), ts, _diff) = view.get(index);
155                    let ts = <T as columnar::Columnar>::into_owned(ts);
156                    if matches!(classify_time(persist_upper, &ts), TimeClass::Eligible) {
157                        if last_probe != Some(key) {
158                            if probe_count == PROBE_WINDOW {
159                                end = index;
160                                break;
161                            }
162                            probe_col.push(key);
163                            probe_count += 1;
164                            last_probe = Some(key);
165                        }
166                    }
167                }
168            }
169
170            // Pass 2: bulk-probe the trace's batches through the chunk
171            // batches' `UnloadChunk` surface and consolidate the hits into
172            // the prior value per key. Hits arrive per batch, so equal
173            // `(key, val)` pairs from different batches are non-adjacent.
174            // Sort before folding.
175            let mut old_values: std::collections::BTreeMap<UpsertKey, UpsertValue> =
176                std::collections::BTreeMap::new();
177            if probe_count > 0 {
178                use columnar::Borrow;
179                let mut staging = <FeedbackUpdate<T> as columnar::Columnar>::Container::default();
180                for batch in &batches {
181                    batch.extract_into(probe_col.borrow(), &mut staging);
182                }
183                let staged = staging.borrow();
184                let mut hits: Vec<_> = (0..staged.len())
185                    .map(|i| {
186                        let ((key, val), _time, diff) = staged.get(i);
187                        (key, val, <Diff as columnar::Columnar>::into_owned(diff))
188                    })
189                    .collect();
190                hits.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
191                let mut i = 0;
192                while i < hits.len() {
193                    let (key, val, _) = hits[i];
194                    let mut count = Diff::ZERO;
195                    let mut j = i;
196                    while j < hits.len() && hits[j].0 == key && hits[j].1 == val {
197                        count += hits[j].2;
198                        j += 1;
199                    }
200                    if count.is_positive() {
201                        assert!(
202                            count == 1.into(),
203                            "unexpected multiple entries for the same key in persist trace"
204                        );
205                        let prev = old_values.insert(*key, decode_upsert_value(val.iter()));
206                        assert!(
207                            prev.is_none(),
208                            "unexpected multiple values for the same key in persist trace"
209                        );
210                    }
211                    i = j;
212                }
213            }
214
215            // Pass 3: classify and emit this window's records.
216            for index in start..end {
217                let ((_, key), ts, diff) = view.get(index);
218                let ts = <T as columnar::Columnar>::into_owned(ts);
219                match classify_time(persist_upper, &ts) {
220                    TimeClass::AlreadyPersisted => continue,
221                    TimeClass::Ineligible => {
222                        // Re-stash for later (owned).
223                        ineligible.push((
224                            *key,
225                            ts,
226                            <UpsertDiff<O> as columnar::Columnar>::into_owned(diff),
227                        ));
228                        continue;
229                    }
230                    TimeClass::Eligible => {}
231                }
232
233                // ts == persist_upper: eligible. The chunk holds one entry per
234                // (key, time) and eligibility pins the time, so this key appears
235                // at most once and its prior value can move out of the map.
236                eligible_count += 1;
237                let old_value = old_values.remove(key);
238
239                if old_value.is_some() {
240                    result_count += 1;
241                }
242
243                match diff.value {
244                    Some(row) => {
245                        if let Some(old_val) = old_value {
246                            let size = upsert_value_byte_len(&old_val);
247                            output_handle
248                                .give_fueled(
249                                    output_cap,
250                                    (old_val, ts.clone(), Diff::MINUS_ONE),
251                                    size,
252                                )
253                                .await;
254                            output_count += 1;
255                            updates += 1;
256                        } else {
257                            inserts += 1;
258                        }
259                        let new_val = decode_upsert_value(row.iter());
260                        let size = upsert_value_byte_len(&new_val);
261                        output_handle
262                            .give_fueled(output_cap, (new_val, ts, Diff::ONE), size)
263                            .await;
264                        output_count += 1;
265                    }
266                    None => {
267                        if let Some(old_val) = old_value {
268                            let size = upsert_value_byte_len(&old_val);
269                            output_handle
270                                .give_fueled(output_cap, (old_val, ts, Diff::MINUS_ONE), size)
271                                .await;
272                            output_count += 1;
273                            deletes += 1;
274                        }
275                    }
276                }
277            }
278            start = end;
279        }
280    }
281
282    tracing::debug!(
283        worker_id = %worker_id,
284        source_id = %source_id,
285        ineligible = ineligible.len(),
286        eligible = eligible_count,
287        "drained stash",
288    );
289
290    DrainStats {
291        eligible: eligible_count,
292        result_count,
293        inserts,
294        updates,
295        deletes,
296        output_count,
297    }
298}
299
300/// [`drain_sealed_input_chunked`]'s counterpart for the paged flavor,
301/// classifying entries the same way but reading prior state through a trace
302/// cursor.
303///
304/// The sealed chunks are already sorted and consolidated by the merge
305/// batcher, so the trace cursor walks forward through keys in order and
306/// seeks amortize. Entries are walked by reference rather than collecting
307/// the eligible set into an owned Vec, and eligible values are emitted
308/// straight from the column's `RowRef` with no owned `UpsertDiff` copy. Only
309/// the re-stashed ineligible set is materialized.
310pub(super) async fn drain_sealed_input_paged<T, O>(
311    sealed: Vec<Column<UpsertUpdate<T, O>>>,
312    ineligible: &mut Vec<UpsertUpdate<T, O>>,
313    output_handle: &UpsertOutputHandle<T>,
314    output_cap: &Capability<T>,
315    persist_upper: &Antichain<T>,
316    trace: &mut TraceAgent<FundedValRowSpine<UpsertKey, T, Diff>>,
317    worker_id: usize,
318    source_id: GlobalId,
319) -> DrainStats
320where
321    T: Timestamp + TotalOrder + Lattice + Sync,
322    T: columnation::Columnation + columnar::Columnar,
323    O: columnar::Columnar,
324{
325    use columnar::Index as _;
326
327    // Classify each entry by its timestamp relative to `persist_upper`:
328    //
329    //   * `ts == persist_upper`: eligible for processing now.
330    //   * `ts >  persist_upper`: not yet processable; re-stashed (ineligible)
331    //     until the feedback frontier catches up to it.
332    //   * `ts <  persist_upper`: already persisted by some writer and not
333    //     relevant anymore. We DROP it. The downstream persist_sink would
334    //     filter such updates out anyway since the shard upper is further
335    //     ahead, and our state is already up-to-date to `persist_upper` so we
336    //     could not emit correct retractions for it. Re-stashing it would
337    //     strand the data forever (`persist_upper` only advances, so
338    //     `ts == persist_upper` can never again hold) and pin the operator's
339    //     output frontier below the shard upper. This mirrors v1's
340    //     `relevant = persist_upper.less_equal(ts)`.
341    let mut eligible_count: u64 = 0;
342    let mut result_count: u64 = 0;
343    let mut output_count: u64 = 0;
344    let mut inserts: u64 = 0;
345    let mut updates: u64 = 0;
346    let mut deletes: u64 = 0;
347
348    let batches = trace
349        .batches_through(Antichain::new().borrow())
350        .expect("complete batch set for the feedback trace; is it closed?");
351    let (mut cursor, storage) = cursor_list(batches);
352
353    for chunk in &sealed {
354        for (key, ts, diff) in chunk.borrow().into_index_iter() {
355            let ts = <T as columnar::Columnar>::into_owned(ts);
356            match classify_time(persist_upper, &ts) {
357                TimeClass::AlreadyPersisted => continue,
358                TimeClass::Ineligible => {
359                    // Re-stash for later (owned).
360                    ineligible.push((
361                        *key,
362                        ts,
363                        <UpsertDiff<O> as columnar::Columnar>::into_owned(diff),
364                    ));
365                    continue;
366                }
367                TimeClass::Eligible => {}
368            }
369
370            // ts == persist_upper: eligible. Look up the prior value for this
371            // key in the persist trace and emit the retraction / insertion. The
372            // spine stores keys in a columnation arena, so we seek by the
373            // column's borrowed `&UpsertKey` directly.
374            eligible_count += 1;
375            cursor.seek_key(&storage, key);
376            let old_value = match cursor.get_key(&storage) {
377                Some(found) if found == key => {
378                    let mut result = None;
379                    while let Some(val) = cursor.get_val(&storage) {
380                        let mut count = Diff::ZERO;
381                        cursor.map_times(&storage, |_time, d| {
382                            count += d.clone();
383                        });
384                        if count.is_positive() {
385                            assert!(
386                                count == 1.into(),
387                                "unexpected multiple entries for the same key in persist trace"
388                            );
389                            assert!(
390                                result.is_none(),
391                                "unexpected multiple values for the same key in persist trace"
392                            );
393                            result = Some(decode_upsert_value(val));
394                        }
395                        cursor.step_val(&storage);
396                    }
397                    result
398                }
399                _ => None,
400            };
401
402            if old_value.is_some() {
403                result_count += 1;
404            }
405
406            match diff.value {
407                Some(row) => {
408                    if let Some(old_val) = old_value {
409                        let size = upsert_value_byte_len(&old_val);
410                        output_handle
411                            .give_fueled(output_cap, (old_val, ts.clone(), Diff::MINUS_ONE), size)
412                            .await;
413                        output_count += 1;
414                        updates += 1;
415                    } else {
416                        inserts += 1;
417                    }
418                    let new_val = decode_upsert_value(row.iter());
419                    let size = upsert_value_byte_len(&new_val);
420                    output_handle
421                        .give_fueled(output_cap, (new_val, ts, Diff::ONE), size)
422                        .await;
423                    output_count += 1;
424                }
425                None => {
426                    if let Some(old_val) = old_value {
427                        let size = upsert_value_byte_len(&old_val);
428                        output_handle
429                            .give_fueled(output_cap, (old_val, ts, Diff::MINUS_ONE), size)
430                            .await;
431                        output_count += 1;
432                        deletes += 1;
433                    }
434                }
435            }
436        }
437    }
438
439    tracing::debug!(
440        worker_id = %worker_id,
441        source_id = %source_id,
442        ineligible = ineligible.len(),
443        eligible = eligible_count,
444        "drained stash",
445    );
446
447    DrainStats {
448        eligible: eligible_count,
449        result_count,
450        inserts,
451        updates,
452        deletes,
453        output_count,
454    }
455}