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}