Skip to main content

mz_compute/extensions/
temporal_bucket.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//! Utilities and stream extensions for temporal bucketing.
11
12use std::hash::Hash;
13
14use columnar::{Columnar, Index, Len, Push};
15use differential_dataflow::Hashable;
16use differential_dataflow::difference::Semigroup;
17use differential_dataflow::lattice::Lattice;
18use differential_dataflow::trace::Batcher;
19use mz_timely_util::columnar::Column;
20use mz_timely_util::columnar::builder::ColumnBuilder;
21use mz_timely_util::columnar::chunk::{AccountedChunkBatcher, ChunkChunker, ComputeSpill};
22use mz_timely_util::columnar::columnar_exchange_data;
23use mz_timely_util::temporal::{Bucket, BucketChain, BucketRange, BucketTimestamp};
24use timely::Accountable;
25use timely::ExchangeData;
26use timely::container::{CapacityContainerBuilder, PushInto};
27use timely::dataflow::channels::pact::{Exchange, ExchangeCore};
28use timely::dataflow::operators::Operator;
29use timely::dataflow::{Stream, StreamVec};
30use timely::order::TotalOrder;
31use timely::progress::{Antichain, PathSummary, Timestamp};
32
33use crate::typedefs::MzData;
34
35/// Sort outstanding updates into a [`BucketChain`], and reveal data not in advance of the input
36/// frontier. Retains a capability at the last input frontier to retain the right to produce data
37/// at times between the last input frontier and the current input frontier.
38pub trait TemporalBucketing<'scope, T: Timestamp>: Sized {
39    /// Construct a new stream that stores updates into a [`BucketChain`] and reveals data
40    /// not in advance of the frontier. Data that is within `threshold` distance of the input
41    /// frontier or the `as_of` is passed through without being stored in the chain.
42    ///
43    /// The output container matches the input's, so a caller keeps whichever
44    /// representation it had.
45    fn bucket(self, as_of: Antichain<T>, threshold: T::Summary) -> Self;
46}
47
48/// Implementation for streams in scopes where timestamps define a total order.
49impl<'scope, T, D> TemporalBucketing<'scope, T> for Stream<'scope, T, Column<(D, T, mz_repr::Diff)>>
50where
51    T: Timestamp + Default + ExchangeData + MzData + BucketTimestamp + TotalOrder + Lattice,
52    for<'a> columnar::Ref<'a, T>: Copy + Ord,
53    D: ExchangeData + MzData + Ord + Clone + std::fmt::Debug + Hashable,
54    for<'a> columnar::Ref<'a, D>: Copy + Ord + Hash,
55    for<'a> columnar::Ref<'a, mz_repr::Diff>: Ord,
56    for<'a> <(D, T, mz_repr::Diff) as Columnar>::Container:
57        Push<columnar::Ref<'a, (D, T, mz_repr::Diff)>>,
58{
59    fn bucket(self, as_of: Antichain<T>, threshold: T::Summary) -> Self {
60        let scope = self.scope();
61        let logger = scope
62            .worker()
63            .logger_for("differential/arrange")
64            .map(Into::into);
65
66        type CB<D, T> = CapacityContainerBuilder<Column<(D, T, mz_repr::Diff)>>;
67
68        let pact = ExchangeCore::<ColumnBuilder<_>, _>::new_core(
69            columnar_exchange_data::<D, T, mz_repr::Diff>,
70        );
71        self.unary_frontier::<CB<D, T>, _, _, _>(pact, "Temporal delay", |cap, info| {
72            let mut chain = BucketChain::new(MergeBatcherWrapper::new(logger, info.global_id));
73            let activator = scope.activator_for(info.address);
74
75            // Cap tracking the lower bound of potentially outstanding data.
76            let mut cap = Some(cap);
77
78            // Holds one bucket's worth of updates on the way into the chain.
79            // Reused across activations for its allocation.
80            let mut buffer: Column<(D, T, mz_repr::Diff)> = Default::default();
81            // Reused input permutation, ordered by time.
82            let mut permutation: Vec<usize> = Vec::new();
83            // Reused so reading a record's time does not allocate: an iterative `T`
84            // owns a `PointStamp`'s allocation.
85            let mut time_buf = T::minimum();
86
87            move |(input, frontier), output| {
88                // The upper frontier is the join of the input frontier and the `as_of` frontier,
89                // with the `threshold` summary applied to it.
90                let mut upper = Antichain::new();
91                for time1 in &frontier.frontier() {
92                    for time2 in as_of.elements() {
93                        // TODO: Use `join_assign` if we ever use a timestamp with allocations.
94                        if let Some(time) = threshold.results_in(&time1.join(time2)) {
95                            upper.insert(time);
96                        }
97                    }
98                }
99
100                input.for_each_time(|time, data| {
101                    let mut session = output.session_with_builder(&time);
102                    for data in data {
103                        let borrowed = data.borrow();
104
105                        // Pass through data about to be revealed, and retain the
106                        // index of everything the chain has to hold. Only the
107                        // retained records need ordering, and in steady state the
108                        // pass-through share is the larger one.
109                        permutation.clear();
110                        for index in 0..borrowed.len() {
111                            let update = borrowed.get(index);
112                            time_buf.copy_from(update.1);
113                            if upper.less_equal(&time_buf) {
114                                permutation.push(index);
115                            } else {
116                                session.give(update);
117                            }
118                        }
119
120                        // Order the retained records by time so each bucket's
121                        // records land contiguously below. Sorting indices keeps
122                        // the records in place.
123                        permutation.sort_unstable_by_key(|index| borrowed.get(*index).1);
124
125                        // The range `buffer`'s contents belong to, `None` while empty.
126                        let mut buffered_range = None;
127                        for index in permutation.drain(..) {
128                            let update = borrowed.get(index);
129                            time_buf.copy_from(update.1);
130
131                            // Ship the buffer whenever the bucket changes, which
132                            // the time order makes a single transition per bucket.
133                            let contained = match &buffered_range {
134                                Some(range) => BucketRange::contains(range, &time_buf),
135                                None => false,
136                            };
137                            if !contained {
138                                if let Some(range) = buffered_range.take() {
139                                    let bucket = chain.find_mut(&range.start).expect("Must exist");
140                                    bucket.push_container(&mut buffer);
141                                }
142                                buffered_range =
143                                    Some(chain.range_of(&time_buf).expect("Must exist"));
144                            }
145                            buffer.push_into(update);
146                        }
147
148                        // Handle leftover data in the buffer.
149                        if let Some(range) = buffered_range.take() {
150                            let bucket = chain.find_mut(&range.start).expect("Must exist");
151                            bucket.push_container(&mut buffer);
152                        }
153                    }
154                });
155
156                // Check for data that is ready to be revealed.
157                let peeled = chain.peel(upper.borrow());
158                if let Some(cap) = cap.as_ref() {
159                    let mut session = output.session_with_builder(cap);
160                    for mut column in peeled.into_iter().flat_map(|x| x.done()) {
161                        session.give_container(&mut column);
162                    }
163                } else {
164                    // If we don't have a cap, we should not have any data to reveal.
165                    assert!(
166                        peeled
167                            .into_iter()
168                            .flat_map(|x| x.done())
169                            .all(|column| column.record_count() == 0),
170                        "Unexpected data revealed without a cap."
171                    );
172                }
173
174                // Downgrade the cap to the current input frontier.
175                if frontier.is_empty() || upper.is_empty() {
176                    cap = None;
177                } else if let Some(cap) = cap.as_mut() {
178                    // TODO: This assumes that the time is total ordered.
179                    cap.downgrade(&upper[0]);
180                }
181
182                // Maintain the bucket chain by restoring it with fuel.
183                let mut fuel = 1_000_000;
184                chain.restore(&mut fuel);
185                if fuel <= 0 {
186                    // If we run out of fuel, we activate the operator to continue processing.
187                    activator.activate();
188                }
189            }
190        })
191    }
192}
193
194/// Implementation for `Vec` streams in scopes where timestamps define a total order.
195///
196/// A caller whose consumer wants owned records keeps a `Vec`-native operator, because
197/// staging the whole stream through a column would copy every pass-through record and
198/// allocate it again on the way out. Only records that enter the chain are encoded, which
199/// they were anyway: the chain's batcher is columnar. The reduce key-value path is the one
200/// such caller, and this implementation goes away once its consumer reads columns.
201impl<'scope, T, D> TemporalBucketing<'scope, T> for StreamVec<'scope, T, (D, T, mz_repr::Diff)>
202where
203    T: Timestamp + Default + ExchangeData + MzData + BucketTimestamp + TotalOrder + Lattice,
204    D: ExchangeData + MzData + Ord + Clone + std::fmt::Debug + Hashable,
205    for<'a> <(D, T, mz_repr::Diff) as Columnar>::Container: Push<&'a (D, T, mz_repr::Diff)>,
206{
207    fn bucket(self, as_of: Antichain<T>, threshold: T::Summary) -> Self {
208        let scope = self.scope();
209        let logger = scope
210            .worker()
211            .logger_for("differential/arrange")
212            .map(Into::into);
213
214        let pact = Exchange::new(|(d, _, _): &(D, T, mz_repr::Diff)| d.hashed().into());
215        self.unary_frontier::<CapacityContainerBuilder<Vec<(D, T, mz_repr::Diff)>>, _, _, _>(
216            pact,
217            "Temporal delay",
218            |cap, info| {
219                let mut chain = BucketChain::new(MergeBatcherWrapper::new(logger, info.global_id));
220                let activator = scope.activator_for(info.address);
221
222                // Cap tracking the lower bound of potentially outstanding data.
223                let mut cap = Some(cap);
224
225                // Staging column for the records of one bucket. The chain's batcher is
226                // columnar, so a stored record is encoded either way.
227                let mut buffer: Column<(D, T, mz_repr::Diff)> = Default::default();
228
229                move |(input, frontier), output| {
230                    // The upper frontier is the join of the input frontier and the `as_of`
231                    // frontier, with the `threshold` summary applied to it.
232                    let mut upper = Antichain::new();
233                    for time1 in &frontier.frontier() {
234                        for time2 in as_of.elements() {
235                            // TODO: Use `join_assign` if we ever use a timestamp with allocations.
236                            if let Some(time) = threshold.results_in(&time1.join(time2)) {
237                                upper.insert(time);
238                            }
239                        }
240                    }
241
242                    input.for_each_time(|time, data| {
243                        let mut session = output.session_with_builder(&time);
244                        for data in data {
245                            // Skip data that is about to be revealed.
246                            let pass_through =
247                                data.extract_if(.., |(_, t, _)| !upper.less_equal(t));
248                            session.give_iterator(pass_through);
249
250                            // Sort data by time, then drain it into a buffer that contains data
251                            // for a single bucket. We scan the data for ranges of time that fall
252                            // into the same bucket so we can push batches of data at once.
253                            data.sort_unstable_by(|(_, t, _), (_, t2, _)| t.cmp(t2));
254
255                            let mut drain = data.drain(..);
256                            if let Some(update) = drain.next() {
257                                let mut range = chain.range_of(&update.1).expect("Must exist");
258                                buffer.push_into(&update);
259                                for update in drain {
260                                    // If we have a range, check if the time is not within it.
261                                    if !range.contains(&update.1) {
262                                        // If the time is outside the range, push the current
263                                        // buffer to the chain and reset the range.
264                                        if !buffer.is_empty() {
265                                            let bucket =
266                                                chain.find_mut(&range.start).expect("Must exist");
267                                            bucket.push_container(&mut buffer);
268                                        }
269                                        range = chain.range_of(&update.1).expect("Must exist");
270                                    }
271                                    buffer.push_into(&update);
272                                }
273
274                                // Handle leftover data in the buffer.
275                                if !buffer.is_empty() {
276                                    let bucket = chain.find_mut(&range.start).expect("Must exist");
277                                    bucket.push_container(&mut buffer);
278                                }
279                            }
280                        }
281                    });
282
283                    // Check for data that is ready to be revealed.
284                    let peeled = chain.peel(upper.borrow());
285                    if let Some(cap) = cap.as_ref() {
286                        let mut session = output.session_with_builder(cap);
287                        for column in peeled.into_iter().flat_map(|x| x.done()) {
288                            session.give_iterator(
289                                column
290                                    .borrow()
291                                    .into_index_iter()
292                                    .map(<(D, T, mz_repr::Diff)>::into_owned),
293                            );
294                        }
295                    } else {
296                        // If we don't have a cap, we should not have any data to reveal.
297                        assert!(
298                            peeled
299                                .into_iter()
300                                .flat_map(|x| x.done())
301                                .all(|column| column.record_count() == 0),
302                            "Unexpected data revealed without a cap."
303                        );
304                    }
305
306                    // Downgrade the cap to the current input frontier.
307                    if frontier.is_empty() || upper.is_empty() {
308                        cap = None;
309                    } else if let Some(cap) = cap.as_mut() {
310                        // TODO: This assumes that the time is total ordered.
311                        cap.downgrade(&upper[0]);
312                    }
313
314                    // Maintain the bucket chain by restoring it with fuel.
315                    let mut fuel = 1_000_000;
316                    chain.restore(&mut fuel);
317                    if fuel <= 0 {
318                        // If we run out of fuel, we activate the operator to continue processing.
319                        activator.activate();
320                    }
321                }
322            },
323        )
324    }
325}
326
327/// A wrapper around [`AccountedChunkBatcher`] that implements the bucketing API.
328///
329/// This is the same merge batcher the arrange sites' chunked arm uses, so the
330/// bucket chain and those arrangements share one merge-batcher implementation.
331/// The choice is unconditional here: the bucket chain does not consult the
332/// arrange batcher selector.
333///
334/// The batcher consumes sorted, consolidated chunks, so this
335/// wrapper carries a [`ChunkChunker`] that sorts and consolidates the input
336/// columns into chunks within the batcher's byte bound.
337///
338/// Updates are stored time-major, as `((time, data), time, diff)`, so each
339/// chunk covers a narrow range of times. Splitting at a time then loads only
340/// the chunks whose time bounds straddle it, and moves every other chunk
341/// whole, spilled or not. With data-major chunks every chunk spans the
342/// bucket's times, and each split loads and rewrites the whole bucket. The
343/// leading copy of the time orders the chunks, and the trailing copy is the
344/// batcher's time, which `seal` partitions by.
345struct MergeBatcherWrapper<D, T, R>
346where
347    D: MzData,
348    T: MzData + Ord + Default + Timestamp + Lattice,
349    R: MzData + Semigroup + Default + for<'a> Semigroup<columnar::Ref<'a, R>>,
350    for<'a> columnar::Ref<'a, R>: Ord,
351{
352    logger: Option<differential_dataflow::logging::Logger>,
353    operator_id: usize,
354    chunker: ChunkChunker<(T, D), T, R, ComputeSpill>,
355    inner: AccountedChunkBatcher<(T, D), T, R, ComputeSpill>,
356}
357
358impl<D, T, R> MergeBatcherWrapper<D, T, R>
359where
360    D: MzData + Ord + Clone,
361    T: MzData + Ord + Clone + Default + Timestamp + Lattice,
362    R: MzData + Semigroup + Default + for<'a> Semigroup<columnar::Ref<'a, R>>,
363    for<'a> columnar::Ref<'a, R>: Ord,
364    for<'a> <D as Columnar>::Container: Push<columnar::Ref<'a, D>>,
365    for<'a> <T as Columnar>::Container: Push<columnar::Ref<'a, T>>,
366    for<'a> <R as Columnar>::Container: Push<&'a R>,
367{
368    /// Construct a new `MergeBatcherWrapper` with the given logger and operator ID.
369    fn new(logger: Option<differential_dataflow::logging::Logger>, operator_id: usize) -> Self {
370        Self {
371            logger: logger.clone(),
372            operator_id,
373            chunker: ChunkChunker::default(),
374            inner: AccountedChunkBatcher::new(logger, operator_id),
375        }
376    }
377
378    /// Consolidate `buffer` through the chunker and feed any complete chunks to
379    /// the batcher. Leaves `buffer` empty, retaining its allocation.
380    fn push_container(&mut self, buffer: &mut Column<(D, T, R)>) {
381        use columnar::Container as _;
382        use timely::container::{ContainerBuilder as _, PushInto as _};
383        if buffer.is_empty() {
384            return;
385        }
386        // A fresh time-major copy per push. Retaining it would keep a
387        // push-sized allocation in every bucket of the chain, outside the
388        // pool and the batcher's size accounting.
389        let mut staging = <((T, D), T, R) as Columnar>::Container::default();
390        let (data, time, diff) = buffer.borrow();
391        staging.0.0.extend_from_self(time, 0..time.len());
392        staging.0.1.extend_from_self(data, 0..data.len());
393        staging.1.extend_from_self(time, 0..time.len());
394        staging.2.extend_from_self(diff, 0..diff.len());
395        buffer.clear();
396        self.chunker.push_into(&mut Column::Typed(staging));
397        while let Some(chunk) = self.chunker.extract() {
398            self.inner.push_into(std::mem::take(chunk));
399        }
400    }
401
402    /// Flush any partial chunk still held by the chunker into the batcher.
403    fn flush(&mut self) {
404        use timely::container::{ContainerBuilder as _, PushInto as _};
405        while let Some(chunk) = self.chunker.finish() {
406            self.inner.push_into(std::mem::take(chunk));
407        }
408    }
409
410    /// Reveal the contents of the merge batcher, as one column per chunk.
411    ///
412    /// A spilled chunk loads when the iterator reaches it.
413    fn done(mut self) -> impl Iterator<Item = Column<(D, T, R)>> {
414        use columnar::Container as _;
415        self.flush();
416        let (chain, _description) = self.inner.seal(Antichain::new());
417        chain.into_iter().map(|chunk| {
418            let body = chunk.into_body();
419            let ((_, data), time, diff) = body.borrow();
420            let mut column = <(D, T, R) as Columnar>::Container::default();
421            column.0.extend_from_self(data, 0..data.len());
422            column.1.extend_from_self(time, 0..time.len());
423            column.2.extend_from_self(diff, 0..diff.len());
424            Column::Typed(column)
425        })
426    }
427}
428
429impl<D, T, R> Bucket for MergeBatcherWrapper<D, T, R>
430where
431    D: MzData + Ord + Clone,
432    T: MzData + Ord + Clone + Default + Lattice + BucketTimestamp,
433    R: MzData + Semigroup + Default + for<'a> Semigroup<columnar::Ref<'a, R>>,
434    for<'a> columnar::Ref<'a, R>: Ord,
435    for<'a> <D as Columnar>::Container: Push<columnar::Ref<'a, D>>,
436    for<'a> <T as Columnar>::Container: Push<columnar::Ref<'a, T>>,
437    for<'a> <R as Columnar>::Container: Push<&'a R>,
438{
439    type Timestamp = T;
440
441    fn split(mut self, timestamp: &Self::Timestamp, fuel: &mut i64) -> (Self, Self) {
442        use timely::container::PushInto as _;
443        self.flush();
444        let upper = Antichain::from_elem(timestamp.clone());
445        let mut lower = Self::new(self.logger.clone(), self.operator_id);
446        // Sealing at `timestamp` ships exactly the updates strictly less than it,
447        // as sorted, consolidated chunks; feed them to the lower batcher whole,
448        // so spilled bodies move without being loaded. The `fuel` charge covers
449        // the sealing work those records paid for.
450        let (chain, _description) = self.inner.seal(upper);
451        for chunk in chain {
452            *fuel = fuel.saturating_sub(chunk.record_count());
453            lower.inner.push_into(chunk);
454        }
455        (lower, self)
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use std::collections::BTreeMap;
462
463    use mz_ore::pool::Pool;
464    use mz_repr::{Diff, Timestamp};
465    use mz_timely_util::columnar::chunk::set_spill_override;
466
467    use super::*;
468
469    type Wrapper = MergeBatcherWrapper<u64, Timestamp, Diff>;
470    type Update = (u64, Timestamp, Diff);
471
472    /// Updates with colliding `(data, time)` pairs and retractions, spread
473    /// over times `[1_000, 101_000)`.
474    fn updates(round: u64) -> Vec<Update> {
475        (0..20_000u64)
476            .map(|i| {
477                let x = (i + round * 20_000).wrapping_mul(0x9E37_79B9_7F4A_7C15);
478                let data = (x >> 8) % 5_000;
479                let time = Timestamp::from(1_000 + (x >> 24) % 100_000);
480                let diff = if x & (1 << 40) == 0 {
481                    Diff::ONE
482                } else {
483                    Diff::MINUS_ONE
484                };
485                (data, time, diff)
486            })
487            .collect()
488    }
489
490    /// Push `updates` into the buckets of `chain` that hold their times, the
491    /// way the operator does.
492    fn insert(chain: &mut BucketChain<Wrapper>, mut updates: Vec<Update>) {
493        updates.sort_by_key(|(_, time, _)| *time);
494        let mut buffer = Column::default();
495        let mut range: Option<BucketRange<Timestamp>> = None;
496        for update in &updates {
497            if !range
498                .as_ref()
499                .is_some_and(|range| range.contains(&update.1))
500            {
501                if let Some(range) = range.take() {
502                    let bucket = chain.find_mut(&range.start).expect("must exist");
503                    bucket.push_container(&mut buffer);
504                }
505                range = Some(chain.range_of(&update.1).expect("must exist"));
506            }
507            buffer.push_into(update);
508        }
509        if let Some(range) = range {
510            let bucket = chain.find_mut(&range.start).expect("must exist");
511            bucket.push_container(&mut buffer);
512        }
513    }
514
515    fn accumulate(
516        map: &mut BTreeMap<(u64, Timestamp), Diff>,
517        updates: impl Iterator<Item = Update>,
518    ) {
519        for (data, time, diff) in updates {
520            *map.entry((data, time)).or_default() += diff;
521        }
522        map.retain(|_, diff| !diff.is_zero());
523    }
524
525    /// Peels at a sequence of frontiers, with updates arriving between peels
526    /// for times in buckets that earlier peels split, and compares each
527    /// peel's output with the reference accumulation of the times it covers.
528    fn peel_matches_reference() {
529        let mut chain = BucketChain::new(Wrapper::new(None, 0));
530        let mut reference = BTreeMap::new();
531        let mut lower = Timestamp::MIN;
532        for round in 0..6u64 {
533            let fresh: Vec<_> = updates(round)
534                .into_iter()
535                .filter(|(_, time, _)| lower <= *time)
536                .collect();
537            accumulate(&mut reference, fresh.iter().copied());
538            insert(&mut chain, fresh);
539
540            let upper = (round < 5).then(|| Timestamp::from(1_000 + (round + 1) * 17_000));
541            let frontier = Antichain::from_iter(upper);
542            let mut revealed = BTreeMap::new();
543            for column in chain
544                .peel(frontier.borrow())
545                .into_iter()
546                .flat_map(|x| x.done())
547            {
548                accumulate(
549                    &mut revealed,
550                    column.borrow().into_index_iter().map(Update::into_owned),
551                );
552            }
553            let mut expected = BTreeMap::new();
554            reference.retain(|(data, time), diff| {
555                if frontier.less_equal(time) {
556                    true
557                } else {
558                    expected.insert((*data, *time), *diff);
559                    false
560                }
561            });
562            assert!(!expected.is_empty(), "round {round} reveals nothing");
563            assert_eq!(revealed, expected, "round {round}");
564
565            let mut fuel = 0;
566            while fuel <= 0 {
567                fuel = 1_000_000;
568                chain.restore(&mut fuel);
569            }
570            lower = upper.unwrap_or(Timestamp::MAX);
571        }
572        assert!(reference.is_empty());
573    }
574
575    #[mz_ore::test]
576    #[cfg_attr(miri, ignore)]
577    fn peel_matches_reference_resident() {
578        peel_matches_reference();
579    }
580
581    #[mz_ore::test]
582    #[cfg_attr(miri, ignore)]
583    fn peel_matches_reference_spilled() {
584        let pool = Pool::new().expect("pool creation");
585        set_spill_override(Some(pool.clone()));
586        peel_matches_reference();
587        set_spill_override(None);
588        assert!(pool.stats().inserts > 0, "nothing spilled");
589    }
590}