Skip to main content

mz_interchange/
envelopes.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
10use std::collections::BTreeMap;
11use std::iter;
12use std::sync::LazyLock;
13
14use differential_dataflow::trace::implementations::BatchContainer;
15use differential_dataflow::trace::{Cursor, Navigable};
16use itertools::EitherOrBoth;
17use maplit::btreemap;
18use mz_ore::cast::CastFrom;
19use mz_repr::{
20    CatalogItemId, ColumnName, Datum, Diff, Row, RowPacker, SqlColumnType, SqlScalarType,
21};
22use timely::progress::Antichain;
23
24use crate::avro::DiffPair;
25
26/// Walks `batch` and invokes `on_diff_pair` for each `DiffPair` at each
27/// `(key, timestamp)`.
28///
29/// Thin wrapper around `iter_diff_pairs`.
30pub fn for_each_diff_pair<B, C, F>(batch: &B, mut on_diff_pair: F)
31where
32    B: Navigable<Cursor = C>,
33    C: Cursor<Storage = B, Diff = Diff>,
34    C::Time: Copy,
35    C::ValOwn: 'static,
36    F: FnMut(&<C::KeyContainer as BatchContainer>::Owned, C::Time, DiffPair<C::ValOwn>),
37{
38    for (key, timed_pairs) in iter_diff_pairs(batch, None, None) {
39        for (time, pair) in timed_pairs {
40            on_diff_pair(&key, time, pair);
41        }
42    }
43}
44
45/// Just like `for_each_diff_pair`, but async and fallible.
46///
47/// Thin wrapper around `iter_diff_pairs`.
48pub async fn for_each_diff_pair_async<B, C, F, E>(
49    batch: &B,
50    lower: Option<Antichain<C::Time>>,
51    upper: Option<Antichain<C::Time>>,
52    mut on_diff_pair: F,
53) -> Result<(), E>
54where
55    B: Navigable<Cursor = C>,
56    C: Cursor<Storage = B, Diff = Diff>,
57    C::Time: Copy,
58    C::ValOwn: 'static,
59    F: AsyncFnMut(
60        &<C::KeyContainer as BatchContainer>::Owned,
61        C::Time,
62        DiffPair<C::ValOwn>,
63    ) -> Result<(), E>,
64{
65    for (key, timed_pairs) in iter_diff_pairs(batch, lower, upper) {
66        for (time, pair) in timed_pairs {
67            on_diff_pair(&key, time, pair).await?;
68        }
69    }
70
71    Ok(())
72}
73
74/// Walks `batch` and emits, for each key, an iterator of the `DiffPair`s at
75/// each timestamp.
76///
77/// Ignores updates outside the specified time range. Inclusive 'lower', exclusive 'upper'.
78///
79/// Within a key, diffs are partitioned by sign into retractions (befores) and
80/// insertions (afters), sorted by timestamp, and zipped into `DiffPair`s via a
81/// merge-join. Pairs are emitted in ascending timestamp order for a given key;
82/// no ordering is guaranteed across keys. Callers are responsible for tracking
83/// `(key, timestamp)` boundaries themselves if they need to detect groups
84/// with more than one pair (e.g., for primary-key violation checks).
85///
86/// The per-key iterator owns its data, so it can be held or consumed after the
87/// outer iterator has advanced. An update with multiplicity `n` fans out into
88/// `n` pairs lazily, cloning the value as pairs are consumed. Memory held per
89/// key is proportional to the number of distinct updates, not the fan-out.
90pub fn iter_diff_pairs<B, C>(
91    batch: &B,
92    lower: Option<Antichain<C::Time>>,
93    upper: Option<Antichain<C::Time>>,
94) -> impl Iterator<
95    Item = (
96        <C::KeyContainer as BatchContainer>::Owned,
97        impl Iterator<Item = (C::Time, DiffPair<C::ValOwn>)>,
98    ),
99>
100where
101    B: Navigable<Cursor = C>,
102    C: Cursor<Storage = B, Diff = Diff>,
103    C::Time: Copy,
104    C::ValOwn: 'static,
105{
106    let mut cursor = batch.cursor();
107    iter::from_fn(move || {
108        while cursor.key_valid(batch) {
109            let k = cursor.key(batch);
110
111            // Partition updates at this key into retractions (befores) and
112            // insertions (afters). The buffers move into the yielded iterator,
113            // so they are per key rather than reused across keys.
114            let mut befores: Vec<(C::Time, C::ValOwn, usize)> = vec![];
115            let mut afters: Vec<(C::Time, C::ValOwn, usize)> = vec![];
116            while cursor.val_valid(batch) {
117                let v = cursor.val(batch);
118                cursor.map_times(batch, |t, diff| {
119                    let t = C::owned_time(t);
120                    if lower.as_ref().is_some_and(|lower| !lower.less_equal(&t))
121                        || upper.as_ref().is_some_and(|upper| upper.less_equal(&t))
122                    {
123                        // This record is outside the specified time interval. Ignore it.
124                        return;
125                    }
126                    let diff = C::owned_diff(diff);
127                    let update = (t, C::owned_val(v), usize::cast_from(diff.unsigned_abs()));
128                    if diff < Diff::ZERO {
129                        befores.push(update);
130                    } else {
131                        afters.push(update);
132                    }
133                });
134                cursor.step_val(batch);
135            }
136
137            if befores.is_empty() && afters.is_empty() {
138                // No records at this key. Try the next one.
139                cursor.step_key(batch);
140                continue;
141            }
142
143            befores.sort_by_key(|(t, _v, _diff)| *t);
144            afters.sort_by_key(|(t, _v, _diff)| *t);
145
146            // The use of `repeat_n()` here is intentional.
147            // Typically, cnt = 1, and `repeat_n((t, v), cnt)` will return the original `(t, v)`.
148            // `iter::repeat((t, v)).take(cnt)` would clone `v` `cnt` times even when `cnt = 1`.
149            let fan_out = |(t, v, cnt): (C::Time, C::ValOwn, usize)| iter::repeat_n((t, v), cnt);
150            let befores_iter = befores.into_iter().flat_map(fan_out);
151            let afters_iter = afters.into_iter().flat_map(fan_out);
152
153            let key_owned = <C::KeyContainer as BatchContainer>::into_owned(k);
154
155            let pairs =
156                itertools::merge_join_by(befores_iter, afters_iter, |(t1, _v1), (t2, _v2)| {
157                    t1.cmp(t2)
158                })
159                .map(|pair| {
160                    let (t, before, after) = match pair {
161                        EitherOrBoth::Both((t, before), (_t, after)) => {
162                            (t, Some(before), Some(after))
163                        }
164                        EitherOrBoth::Left((t, before)) => (t, Some(before), None),
165                        EitherOrBoth::Right((t, after)) => (t, None, Some(after)),
166                    };
167                    (t, DiffPair { before, after })
168                });
169
170            cursor.step_key(batch);
171            return Some((key_owned, pairs));
172        }
173        None
174    })
175}
176
177// NOTE(benesch): statically allocating transient IDs for the
178// transaction and row types is a bit of a hack to allow us to attach
179// custom names to these types in the generated Avro schema. In the
180// future, these types should be real types that get created in the
181// catalog with userspace IDs when the user creates the sink, and their
182// names and IDs should be plumbed in from the catalog at the moment
183// the sink is created.
184pub(crate) const TRANSACTION_TYPE_ID: CatalogItemId = CatalogItemId::Transient(1);
185pub(crate) const DBZ_ROW_TYPE_ID: CatalogItemId = CatalogItemId::Transient(2);
186
187pub static ENVELOPE_CUSTOM_NAMES: LazyLock<BTreeMap<CatalogItemId, String>> = LazyLock::new(|| {
188    btreemap! {
189        TRANSACTION_TYPE_ID => "transaction".into(),
190        DBZ_ROW_TYPE_ID => "row".into(),
191    }
192});
193
194pub(crate) fn dbz_envelope(
195    names_and_types: Vec<(ColumnName, SqlColumnType)>,
196) -> Vec<(ColumnName, SqlColumnType)> {
197    let row = SqlColumnType {
198        nullable: true,
199        scalar_type: SqlScalarType::Record {
200            fields: names_and_types.into(),
201            custom_id: Some(DBZ_ROW_TYPE_ID),
202        },
203    };
204    vec![("before".into(), row.clone()), ("after".into(), row)]
205}
206
207pub fn dbz_format(rp: &mut RowPacker, dp: DiffPair<Row>) {
208    if let Some(before) = dp.before {
209        rp.push_list_with(|rp| rp.extend_by_row(&before));
210    } else {
211        rp.push(Datum::Null);
212    }
213    if let Some(after) = dp.after {
214        rp.push_list_with(|rp| rp.extend_by_row(&after));
215    } else {
216        rp.push(Datum::Null);
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use std::cell::Cell;
223
224    use differential_dataflow::batcher::{Batcher, Extract};
225    use differential_dataflow::trace::Builder;
226    use differential_dataflow::trace::implementations::{ValBatcher, ValBuilder};
227    use timely::progress::Antichain;
228
229    use super::*;
230
231    /// Seals a single batch from an unordered list of `((key, val), time, diff)`
232    /// tuples upper-bounded at `upper`.
233    fn batch_from_tuples_with<V: Ord + Clone + 'static>(
234        mut tuples: Vec<((String, V), u64, Diff)>,
235        upper: u64,
236    ) -> <ValBuilder<String, V, u64, Diff> as Builder>::Output {
237        let mut batcher = ValBatcher::<String, V, u64, Diff>::new(None, 0);
238        // `Batcher` is generic in its input container, so name it.
239        batcher.insert(&mut tuples);
240        let (batch, _frontier) = batcher.extract(Antichain::from_elem(upper).borrow());
241        batch.expect("updates were pushed, so the batch is non-empty")
242    }
243
244    /// `batch_from_tuples_with` pinned to `String` values, so call sites can
245    /// build values with `.into()`.
246    fn batch_from_tuples(
247        tuples: Vec<((String, String), u64, Diff)>,
248        upper: u64,
249    ) -> <ValBuilder<String, String, u64, Diff> as Builder>::Output {
250        batch_from_tuples_with(tuples, upper)
251    }
252
253    /// Collects `for_each_diff_pair` invocations into a flat, deterministically
254    /// sorted list for easy assertion.
255    fn collect_diff_pairs<B, C>(batch: &B) -> Vec<(String, u64, Option<String>, Option<String>)>
256    where
257        B: Navigable<Cursor = C>,
258        C: Cursor<Storage = B, Diff = Diff>,
259        C::Time: Copy + Into<u64>,
260        C::ValOwn: 'static + Into<String>,
261        <C::KeyContainer as BatchContainer>::Owned: Into<String> + Clone,
262    {
263        let mut out = vec![];
264        for_each_diff_pair(batch, |k, t, dp| {
265            out.push((
266                k.clone().into(),
267                t.into(),
268                dp.before.map(Into::into),
269                dp.after.map(Into::into),
270            ));
271        });
272        out.sort();
273        out
274    }
275
276    /// Collects `iter_diff_pairs` output into a per-key list, sorted by key
277    /// for deterministic assertion. Pair order within a key is preserved.
278    fn collect_bounded_diff_pairs<B, C>(
279        batch: &B,
280        lower: Option<u64>,
281        upper: Option<u64>,
282    ) -> Vec<(String, Vec<(u64, Option<String>, Option<String>)>)>
283    where
284        B: Navigable<Cursor = C>,
285        C: Cursor<Storage = B, Diff = Diff, Time = u64>,
286        C::ValOwn: 'static + Into<String>,
287        <C::KeyContainer as BatchContainer>::Owned: Into<String>,
288    {
289        let mut out: Vec<_> = iter_diff_pairs(
290            batch,
291            lower.map(Antichain::from_elem),
292            upper.map(Antichain::from_elem),
293        )
294        .map(|(k, pairs)| {
295            let pairs = pairs
296                .into_iter()
297                .map(|(t, dp)| (t, dp.before.map(Into::into), dp.after.map(Into::into)))
298                .collect();
299            (k.into(), pairs)
300        })
301        .collect();
302        out.sort();
303        out
304    }
305
306    #[mz_ore::test]
307    fn single_insertion() {
308        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, Diff::ONE)], 6);
309        let pairs = collect_diff_pairs(&batch);
310        assert_eq!(pairs, vec![("k1".into(), 5, None, Some("v1".into()))]);
311    }
312
313    #[mz_ore::test]
314    fn single_retraction() {
315        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, -Diff::ONE)], 6);
316        let pairs = collect_diff_pairs(&batch);
317        assert_eq!(pairs, vec![("k1".into(), 5, Some("v1".into()), None)]);
318    }
319
320    #[mz_ore::test]
321    fn update_at_same_timestamp() {
322        // Retract v1 and insert v2 at the same timestamp → paired into a single
323        // DiffPair with both before and after populated.
324        let batch = batch_from_tuples(
325            vec![
326                (("k1".into(), "v1".into()), 5, -Diff::ONE),
327                (("k1".into(), "v2".into()), 5, Diff::ONE),
328            ],
329            6,
330        );
331        let pairs = collect_diff_pairs(&batch);
332        assert_eq!(
333            pairs,
334            vec![("k1".into(), 5, Some("v1".into()), Some("v2".into()))]
335        );
336    }
337
338    #[mz_ore::test]
339    fn update_across_timestamps() {
340        // Insert v1 at t=5, then replace with v2 at t=10.
341        let batch = batch_from_tuples(
342            vec![
343                (("k1".into(), "v1".into()), 5, Diff::ONE),
344                (("k1".into(), "v1".into()), 10, -Diff::ONE),
345                (("k1".into(), "v2".into()), 10, Diff::ONE),
346            ],
347            11,
348        );
349        let pairs = collect_diff_pairs(&batch);
350        assert_eq!(
351            pairs,
352            vec![
353                ("k1".into(), 5, None, Some("v1".into())),
354                ("k1".into(), 10, Some("v1".into()), Some("v2".into())),
355            ]
356        );
357    }
358
359    #[mz_ore::test]
360    fn diff_greater_than_one_fans_out() {
361        // Diff=3 becomes three independent `DiffPair`s at the same timestamp.
362        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, Diff::from(3))], 6);
363        let pairs = collect_diff_pairs(&batch);
364        assert_eq!(
365            pairs,
366            vec![
367                ("k1".into(), 5, None, Some("v1".into())),
368                ("k1".into(), 5, None, Some("v1".into())),
369                ("k1".into(), 5, None, Some("v1".into())),
370            ]
371        );
372    }
373
374    #[mz_ore::test]
375    fn multiple_keys_are_independent() {
376        let batch = batch_from_tuples(
377            vec![
378                (("k1".into(), "v1".into()), 5, Diff::ONE),
379                (("k2".into(), "v2".into()), 5, Diff::ONE),
380            ],
381            6,
382        );
383        let pairs = collect_diff_pairs(&batch);
384        assert_eq!(
385            pairs,
386            vec![
387                ("k1".into(), 5, None, Some("v1".into())),
388                ("k2".into(), 5, None, Some("v2".into())),
389            ]
390        );
391    }
392
393    #[mz_ore::test]
394    fn unpaired_before_and_after_at_different_timestamps() {
395        // Retraction at t=5, insertion at t=10 — they do NOT pair because they
396        // live at different timestamps.
397        let batch = batch_from_tuples(
398            vec![
399                (("k1".into(), "v1".into()), 5, -Diff::ONE),
400                (("k1".into(), "v2".into()), 10, Diff::ONE),
401            ],
402            11,
403        );
404        let pairs = collect_diff_pairs(&batch);
405        assert_eq!(
406            pairs,
407            vec![
408                ("k1".into(), 5, Some("v1".into()), None),
409                ("k1".into(), 10, None, Some("v2".into())),
410            ]
411        );
412    }
413
414    #[mz_ore::test]
415    fn pairs_are_grouped_by_key() {
416        // One item per key, pairs within a key in ascending timestamp order.
417        let batch = batch_from_tuples(
418            vec![
419                (("k1".into(), "v1".into()), 5, Diff::ONE),
420                (("k1".into(), "v1".into()), 10, -Diff::ONE),
421                (("k1".into(), "v2".into()), 10, Diff::ONE),
422                (("k2".into(), "v3".into()), 7, Diff::ONE),
423            ],
424            11,
425        );
426        let items = collect_bounded_diff_pairs(&batch, None, None);
427        assert_eq!(
428            items,
429            vec![
430                (
431                    "k1".into(),
432                    vec![
433                        (5, None, Some("v1".into())),
434                        (10, Some("v1".into()), Some("v2".into())),
435                    ]
436                ),
437                ("k2".into(), vec![(7, None, Some("v3".into()))]),
438            ]
439        );
440    }
441
442    #[mz_ore::test]
443    fn lower_bound_is_inclusive() {
444        let batch = batch_from_tuples(
445            vec![
446                (("k1".into(), "v1".into()), 4, Diff::ONE),
447                (("k1".into(), "v2".into()), 5, Diff::ONE),
448            ],
449            6,
450        );
451        let items = collect_bounded_diff_pairs(&batch, Some(5), None);
452        assert_eq!(
453            items,
454            vec![("k1".into(), vec![(5, None, Some("v2".into()))])]
455        );
456    }
457
458    #[mz_ore::test]
459    fn upper_bound_is_exclusive() {
460        let batch = batch_from_tuples(
461            vec![
462                (("k1".into(), "v1".into()), 5, Diff::ONE),
463                (("k1".into(), "v2".into()), 6, Diff::ONE),
464            ],
465            7,
466        );
467        let items = collect_bounded_diff_pairs(&batch, None, Some(6));
468        assert_eq!(
469            items,
470            vec![("k1".into(), vec![(5, None, Some("v1".into()))])]
471        );
472    }
473
474    #[mz_ore::test]
475    fn bounds_filter_within_a_key() {
476        // The window [6, 11) drops the t=5 insertion but keeps the t=10
477        // retraction/insertion, which still pair with each other.
478        let batch = batch_from_tuples(
479            vec![
480                (("k1".into(), "v1".into()), 5, Diff::ONE),
481                (("k1".into(), "v1".into()), 10, -Diff::ONE),
482                (("k1".into(), "v2".into()), 10, Diff::ONE),
483            ],
484            11,
485        );
486        let items = collect_bounded_diff_pairs(&batch, Some(6), Some(11));
487        assert_eq!(
488            items,
489            vec![(
490                "k1".into(),
491                vec![(10, Some("v1".into()), Some("v2".into()))]
492            )]
493        );
494    }
495
496    #[mz_ore::test]
497    fn fully_filtered_key_emits_no_item() {
498        // All of k1's updates fall outside the bounds, so k1 must not appear
499        // at all, not even as a key with an empty pair list.
500        let batch = batch_from_tuples(
501            vec![
502                (("k1".into(), "v1".into()), 3, Diff::ONE),
503                (("k2".into(), "v2".into()), 5, Diff::ONE),
504            ],
505            6,
506        );
507        let items = collect_bounded_diff_pairs(&batch, Some(5), None);
508        assert_eq!(
509            items,
510            vec![("k2".into(), vec![(5, None, Some("v2".into()))])]
511        );
512    }
513
514    #[mz_ore::test]
515    fn bounds_filter_everything() {
516        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, Diff::ONE)], 6);
517        let items = collect_bounded_diff_pairs(&batch, Some(6), None);
518        assert_eq!(items, vec![]);
519    }
520
521    thread_local! {
522        static TRACKED_LIVE: Cell<usize> = const { Cell::new(0) };
523        static TRACKED_PEAK: Cell<usize> = const { Cell::new(0) };
524    }
525
526    /// Value type that records the peak number of simultaneously live
527    /// instances on this thread, to observe cloning behavior.
528    #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
529    struct Tracked(String);
530
531    impl Tracked {
532        fn new(s: &str) -> Self {
533            Self::record_live();
534            Tracked(s.into())
535        }
536
537        fn record_live() {
538            let live = TRACKED_LIVE.with(|l| {
539                l.set(l.get() + 1);
540                l.get()
541            });
542            TRACKED_PEAK.with(|p| p.set(p.get().max(live)));
543        }
544
545        /// Resets the peak to the current live count and returns that count.
546        fn reset_peak() -> usize {
547            let live = TRACKED_LIVE.with(Cell::get);
548            TRACKED_PEAK.with(|p| p.set(live));
549            live
550        }
551
552        fn peak() -> usize {
553            TRACKED_PEAK.with(Cell::get)
554        }
555    }
556
557    impl Clone for Tracked {
558        fn clone(&self) -> Self {
559            Self::record_live();
560            Tracked(self.0.clone())
561        }
562    }
563
564    impl Drop for Tracked {
565        fn drop(&mut self) {
566            TRACKED_LIVE.with(|l| l.set(l.get() - 1));
567        }
568    }
569
570    #[mz_ore::test]
571    fn fan_out_clones_lazily() {
572        // A hot key: one consolidated update whose diff fans out into many
573        // pairs. Consuming the pairs one at a time must not materialize the
574        // fan-out; only the buffered update and the pair in flight may hold a
575        // clone of the value. An implementation that collects the fan-out
576        // (e.g. into a per-key Vec) peaks at FAN_OUT live clones instead and
577        // regresses sink memory in proportion to the hottest key.
578        const FAN_OUT: i64 = 1000;
579        let batch = batch_from_tuples_with(
580            vec![(("k1".into(), Tracked::new("v1")), 5, Diff::from(FAN_OUT))],
581            6,
582        );
583
584        let baseline = Tracked::reset_peak();
585        let mut pair_count = 0i64;
586        for (_key, pairs) in iter_diff_pairs(&batch, None, None) {
587            for (_time, pair) in pairs {
588                pair_count += 1;
589                drop(pair);
590            }
591        }
592        assert_eq!(pair_count, FAN_OUT);
593
594        let peak_extra = Tracked::peak() - baseline;
595        assert!(
596            peak_extra <= 3,
597            "walking the fan-out held {peak_extra} extra live values at peak, \
598             expected O(1) rather than O(FAN_OUT)"
599        );
600    }
601}