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::{BatchReader, 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: BatchReader<Time = C::Time> + 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: BatchReader<Time = C::Time> + 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: BatchReader<Time = C::Time> + 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::trace::implementations::chunker::ContainerChunker;
225    use differential_dataflow::trace::implementations::{ValBatcher, ValBuilder};
226    use differential_dataflow::trace::{Batcher, Builder};
227    use timely::container::{ContainerBuilder, PushInto};
228    use timely::progress::Antichain;
229
230    use super::*;
231
232    /// Seals a single batch from an unordered list of `((key, val), time, diff)`
233    /// tuples upper-bounded at `upper`.
234    fn batch_from_tuples_with<V: Ord + Clone + 'static>(
235        mut tuples: Vec<((String, V), u64, Diff)>,
236        upper: u64,
237    ) -> <ValBuilder<String, V, u64, Diff> as Builder>::Output {
238        // The batcher consumes already-chunked input via `PushInto`; chunking
239        // is the caller's responsibility.
240        let mut batcher = ValBatcher::<String, V, u64, Diff>::new(None, 0);
241        let mut chunker = ContainerChunker::<Vec<((String, V), u64, Diff)>>::default();
242        chunker.push_into(&mut tuples);
243        while let Some(chunk) = chunker.extract() {
244            batcher.push_into(std::mem::take(chunk));
245        }
246        while let Some(chunk) = chunker.finish() {
247            batcher.push_into(std::mem::take(chunk));
248        }
249        let (mut chain, description) = batcher.seal(Antichain::from_elem(upper));
250        ValBuilder::<String, V, u64, Diff>::seal(&mut chain, description)
251    }
252
253    /// `batch_from_tuples_with` pinned to `String` values, so call sites can
254    /// build values with `.into()`.
255    fn batch_from_tuples(
256        tuples: Vec<((String, String), u64, Diff)>,
257        upper: u64,
258    ) -> <ValBuilder<String, String, u64, Diff> as Builder>::Output {
259        batch_from_tuples_with(tuples, upper)
260    }
261
262    /// Collects `for_each_diff_pair` invocations into a flat, deterministically
263    /// sorted list for easy assertion.
264    fn collect_diff_pairs<B, C>(batch: &B) -> Vec<(String, u64, Option<String>, Option<String>)>
265    where
266        B: BatchReader<Time = C::Time> + Navigable<Cursor = C>,
267        C: Cursor<Storage = B, Diff = Diff>,
268        C::Time: Copy + Into<u64>,
269        C::ValOwn: 'static + Into<String>,
270        <C::KeyContainer as BatchContainer>::Owned: Into<String> + Clone,
271    {
272        let mut out = vec![];
273        for_each_diff_pair(batch, |k, t, dp| {
274            out.push((
275                k.clone().into(),
276                t.into(),
277                dp.before.map(Into::into),
278                dp.after.map(Into::into),
279            ));
280        });
281        out.sort();
282        out
283    }
284
285    /// Collects `iter_diff_pairs` output into a per-key list, sorted by key
286    /// for deterministic assertion. Pair order within a key is preserved.
287    fn collect_bounded_diff_pairs<B, C>(
288        batch: &B,
289        lower: Option<u64>,
290        upper: Option<u64>,
291    ) -> Vec<(String, Vec<(u64, Option<String>, Option<String>)>)>
292    where
293        B: BatchReader<Time = u64> + Navigable<Cursor = C>,
294        C: Cursor<Storage = B, Diff = Diff, Time = u64>,
295        C::ValOwn: 'static + Into<String>,
296        <C::KeyContainer as BatchContainer>::Owned: Into<String>,
297    {
298        let mut out: Vec<_> = iter_diff_pairs(
299            batch,
300            lower.map(Antichain::from_elem),
301            upper.map(Antichain::from_elem),
302        )
303        .map(|(k, pairs)| {
304            let pairs = pairs
305                .into_iter()
306                .map(|(t, dp)| (t, dp.before.map(Into::into), dp.after.map(Into::into)))
307                .collect();
308            (k.into(), pairs)
309        })
310        .collect();
311        out.sort();
312        out
313    }
314
315    #[mz_ore::test]
316    fn single_insertion() {
317        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, Diff::ONE)], 6);
318        let pairs = collect_diff_pairs(&batch);
319        assert_eq!(pairs, vec![("k1".into(), 5, None, Some("v1".into()))]);
320    }
321
322    #[mz_ore::test]
323    fn single_retraction() {
324        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, -Diff::ONE)], 6);
325        let pairs = collect_diff_pairs(&batch);
326        assert_eq!(pairs, vec![("k1".into(), 5, Some("v1".into()), None)]);
327    }
328
329    #[mz_ore::test]
330    fn update_at_same_timestamp() {
331        // Retract v1 and insert v2 at the same timestamp → paired into a single
332        // DiffPair with both before and after populated.
333        let batch = batch_from_tuples(
334            vec![
335                (("k1".into(), "v1".into()), 5, -Diff::ONE),
336                (("k1".into(), "v2".into()), 5, Diff::ONE),
337            ],
338            6,
339        );
340        let pairs = collect_diff_pairs(&batch);
341        assert_eq!(
342            pairs,
343            vec![("k1".into(), 5, Some("v1".into()), Some("v2".into()))]
344        );
345    }
346
347    #[mz_ore::test]
348    fn update_across_timestamps() {
349        // Insert v1 at t=5, then replace with v2 at t=10.
350        let batch = batch_from_tuples(
351            vec![
352                (("k1".into(), "v1".into()), 5, Diff::ONE),
353                (("k1".into(), "v1".into()), 10, -Diff::ONE),
354                (("k1".into(), "v2".into()), 10, Diff::ONE),
355            ],
356            11,
357        );
358        let pairs = collect_diff_pairs(&batch);
359        assert_eq!(
360            pairs,
361            vec![
362                ("k1".into(), 5, None, Some("v1".into())),
363                ("k1".into(), 10, Some("v1".into()), Some("v2".into())),
364            ]
365        );
366    }
367
368    #[mz_ore::test]
369    fn diff_greater_than_one_fans_out() {
370        // Diff=3 becomes three independent `DiffPair`s at the same timestamp.
371        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, Diff::from(3))], 6);
372        let pairs = collect_diff_pairs(&batch);
373        assert_eq!(
374            pairs,
375            vec![
376                ("k1".into(), 5, None, Some("v1".into())),
377                ("k1".into(), 5, None, Some("v1".into())),
378                ("k1".into(), 5, None, Some("v1".into())),
379            ]
380        );
381    }
382
383    #[mz_ore::test]
384    fn multiple_keys_are_independent() {
385        let batch = batch_from_tuples(
386            vec![
387                (("k1".into(), "v1".into()), 5, Diff::ONE),
388                (("k2".into(), "v2".into()), 5, Diff::ONE),
389            ],
390            6,
391        );
392        let pairs = collect_diff_pairs(&batch);
393        assert_eq!(
394            pairs,
395            vec![
396                ("k1".into(), 5, None, Some("v1".into())),
397                ("k2".into(), 5, None, Some("v2".into())),
398            ]
399        );
400    }
401
402    #[mz_ore::test]
403    fn unpaired_before_and_after_at_different_timestamps() {
404        // Retraction at t=5, insertion at t=10 — they do NOT pair because they
405        // live at different timestamps.
406        let batch = batch_from_tuples(
407            vec![
408                (("k1".into(), "v1".into()), 5, -Diff::ONE),
409                (("k1".into(), "v2".into()), 10, Diff::ONE),
410            ],
411            11,
412        );
413        let pairs = collect_diff_pairs(&batch);
414        assert_eq!(
415            pairs,
416            vec![
417                ("k1".into(), 5, Some("v1".into()), None),
418                ("k1".into(), 10, None, Some("v2".into())),
419            ]
420        );
421    }
422
423    #[mz_ore::test]
424    fn pairs_are_grouped_by_key() {
425        // One item per key, pairs within a key in ascending timestamp order.
426        let batch = batch_from_tuples(
427            vec![
428                (("k1".into(), "v1".into()), 5, Diff::ONE),
429                (("k1".into(), "v1".into()), 10, -Diff::ONE),
430                (("k1".into(), "v2".into()), 10, Diff::ONE),
431                (("k2".into(), "v3".into()), 7, Diff::ONE),
432            ],
433            11,
434        );
435        let items = collect_bounded_diff_pairs(&batch, None, None);
436        assert_eq!(
437            items,
438            vec![
439                (
440                    "k1".into(),
441                    vec![
442                        (5, None, Some("v1".into())),
443                        (10, Some("v1".into()), Some("v2".into())),
444                    ]
445                ),
446                ("k2".into(), vec![(7, None, Some("v3".into()))]),
447            ]
448        );
449    }
450
451    #[mz_ore::test]
452    fn lower_bound_is_inclusive() {
453        let batch = batch_from_tuples(
454            vec![
455                (("k1".into(), "v1".into()), 4, Diff::ONE),
456                (("k1".into(), "v2".into()), 5, Diff::ONE),
457            ],
458            6,
459        );
460        let items = collect_bounded_diff_pairs(&batch, Some(5), None);
461        assert_eq!(
462            items,
463            vec![("k1".into(), vec![(5, None, Some("v2".into()))])]
464        );
465    }
466
467    #[mz_ore::test]
468    fn upper_bound_is_exclusive() {
469        let batch = batch_from_tuples(
470            vec![
471                (("k1".into(), "v1".into()), 5, Diff::ONE),
472                (("k1".into(), "v2".into()), 6, Diff::ONE),
473            ],
474            7,
475        );
476        let items = collect_bounded_diff_pairs(&batch, None, Some(6));
477        assert_eq!(
478            items,
479            vec![("k1".into(), vec![(5, None, Some("v1".into()))])]
480        );
481    }
482
483    #[mz_ore::test]
484    fn bounds_filter_within_a_key() {
485        // The window [6, 11) drops the t=5 insertion but keeps the t=10
486        // retraction/insertion, which still pair with each other.
487        let batch = batch_from_tuples(
488            vec![
489                (("k1".into(), "v1".into()), 5, Diff::ONE),
490                (("k1".into(), "v1".into()), 10, -Diff::ONE),
491                (("k1".into(), "v2".into()), 10, Diff::ONE),
492            ],
493            11,
494        );
495        let items = collect_bounded_diff_pairs(&batch, Some(6), Some(11));
496        assert_eq!(
497            items,
498            vec![(
499                "k1".into(),
500                vec![(10, Some("v1".into()), Some("v2".into()))]
501            )]
502        );
503    }
504
505    #[mz_ore::test]
506    fn fully_filtered_key_emits_no_item() {
507        // All of k1's updates fall outside the bounds, so k1 must not appear
508        // at all, not even as a key with an empty pair list.
509        let batch = batch_from_tuples(
510            vec![
511                (("k1".into(), "v1".into()), 3, Diff::ONE),
512                (("k2".into(), "v2".into()), 5, Diff::ONE),
513            ],
514            6,
515        );
516        let items = collect_bounded_diff_pairs(&batch, Some(5), None);
517        assert_eq!(
518            items,
519            vec![("k2".into(), vec![(5, None, Some("v2".into()))])]
520        );
521    }
522
523    #[mz_ore::test]
524    fn bounds_filter_everything() {
525        let batch = batch_from_tuples(vec![(("k1".into(), "v1".into()), 5, Diff::ONE)], 6);
526        let items = collect_bounded_diff_pairs(&batch, Some(6), None);
527        assert_eq!(items, vec![]);
528    }
529
530    thread_local! {
531        static TRACKED_LIVE: Cell<usize> = const { Cell::new(0) };
532        static TRACKED_PEAK: Cell<usize> = const { Cell::new(0) };
533    }
534
535    /// Value type that records the peak number of simultaneously live
536    /// instances on this thread, to observe cloning behavior.
537    #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
538    struct Tracked(String);
539
540    impl Tracked {
541        fn new(s: &str) -> Self {
542            Self::record_live();
543            Tracked(s.into())
544        }
545
546        fn record_live() {
547            let live = TRACKED_LIVE.with(|l| {
548                l.set(l.get() + 1);
549                l.get()
550            });
551            TRACKED_PEAK.with(|p| p.set(p.get().max(live)));
552        }
553
554        /// Resets the peak to the current live count and returns that count.
555        fn reset_peak() -> usize {
556            let live = TRACKED_LIVE.with(Cell::get);
557            TRACKED_PEAK.with(|p| p.set(live));
558            live
559        }
560
561        fn peak() -> usize {
562            TRACKED_PEAK.with(Cell::get)
563        }
564    }
565
566    impl Clone for Tracked {
567        fn clone(&self) -> Self {
568            Self::record_live();
569            Tracked(self.0.clone())
570        }
571    }
572
573    impl Drop for Tracked {
574        fn drop(&mut self) {
575            TRACKED_LIVE.with(|l| l.set(l.get() - 1));
576        }
577    }
578
579    #[mz_ore::test]
580    fn fan_out_clones_lazily() {
581        // A hot key: one consolidated update whose diff fans out into many
582        // pairs. Consuming the pairs one at a time must not materialize the
583        // fan-out; only the buffered update and the pair in flight may hold a
584        // clone of the value. An implementation that collects the fan-out
585        // (e.g. into a per-key Vec) peaks at FAN_OUT live clones instead and
586        // regresses sink memory in proportion to the hottest key.
587        const FAN_OUT: i64 = 1000;
588        let batch = batch_from_tuples_with(
589            vec![(("k1".into(), Tracked::new("v1")), 5, Diff::from(FAN_OUT))],
590            6,
591        );
592
593        let baseline = Tracked::reset_peak();
594        let mut pair_count = 0i64;
595        for (_key, pairs) in iter_diff_pairs(&batch, None, None) {
596            for (_time, pair) in pairs {
597                pair_count += 1;
598                drop(pair);
599            }
600        }
601        assert_eq!(pair_count, FAN_OUT);
602
603        let peak_extra = Tracked::peak() - baseline;
604        assert!(
605            peak_extra <= 3,
606            "walking the fan-out held {peak_extra} extra live values at peak, \
607             expected O(1) rather than O(FAN_OUT)"
608        );
609    }
610}