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::batcher::ColumnChunker;
21use mz_timely_util::columnar::builder::ColumnBuilder;
22use mz_timely_util::columnar::columnar_exchange_data;
23use mz_timely_util::columnar::merge_batcher::ColumnMergeBatcher;
24use mz_timely_util::temporal::{Bucket, BucketChain, BucketRange, BucketTimestamp};
25use timely::Accountable;
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};
32use timely::{ExchangeData, PartialOrder};
33
34use crate::typedefs::MzData;
35
36/// Sort outstanding updates into a [`BucketChain`], and reveal data not in advance of the input
37/// frontier. Retains a capability at the last input frontier to retain the right to produce data
38/// at times between the last input frontier and the current input frontier.
39pub trait TemporalBucketing<'scope, T: Timestamp>: Sized {
40    /// Construct a new stream that stores updates into a [`BucketChain`] and reveals data
41    /// not in advance of the frontier. Data that is within `threshold` distance of the input
42    /// frontier or the `as_of` is passed through without being stored in the chain.
43    ///
44    /// The output container matches the input's, so a caller keeps whichever
45    /// representation it had.
46    fn bucket(self, as_of: Antichain<T>, threshold: T::Summary) -> Self;
47}
48
49/// Implementation for streams in scopes where timestamps define a total order.
50impl<'scope, T, D> TemporalBucketing<'scope, T> for Stream<'scope, T, Column<(D, T, mz_repr::Diff)>>
51where
52    T: Timestamp + Default + ExchangeData + MzData + BucketTimestamp + TotalOrder + Lattice,
53    for<'a> columnar::Ref<'a, T>: Copy + Ord,
54    D: ExchangeData + MzData + Ord + Clone + std::fmt::Debug + Hashable,
55    for<'a> columnar::Ref<'a, D>: Copy + Ord + Hash,
56    for<'a> columnar::Ref<'a, mz_repr::Diff>: Ord,
57    for<'a> <(D, T, mz_repr::Diff) as Columnar>::Container:
58        Push<columnar::Ref<'a, (D, T, mz_repr::Diff)>>,
59{
60    fn bucket(self, as_of: Antichain<T>, threshold: T::Summary) -> Self {
61        let scope = self.scope();
62        let logger = scope
63            .worker()
64            .logger_for("differential/arrange")
65            .map(Into::into);
66
67        type CB<D, T> = CapacityContainerBuilder<Column<(D, T, mz_repr::Diff)>>;
68
69        let pact = ExchangeCore::<ColumnBuilder<_>, _>::new_core(
70            columnar_exchange_data::<D, T, mz_repr::Diff>,
71        );
72        self.unary_frontier::<CB<D, T>, _, _, _>(pact, "Temporal delay", |cap, info| {
73            let mut chain = BucketChain::new(MergeBatcherWrapper::new(logger, info.global_id));
74            let activator = scope.activator_for(info.address);
75
76            // Cap tracking the lower bound of potentially outstanding data.
77            let mut cap = Some(cap);
78
79            // Holds one bucket's worth of updates on the way into the chain.
80            // Reused across activations for its allocation.
81            let mut buffer: Column<(D, T, mz_repr::Diff)> = Default::default();
82            // Reused input permutation, ordered by time.
83            let mut permutation: Vec<usize> = Vec::new();
84            // Reused so reading a record's time does not allocate: an iterative `T`
85            // owns a `PointStamp`'s allocation.
86            let mut time_buf = T::minimum();
87
88            move |(input, frontier), output| {
89                // The upper frontier is the join of the input frontier and the `as_of` frontier,
90                // with the `threshold` summary applied to it.
91                let mut upper = Antichain::new();
92                for time1 in &frontier.frontier() {
93                    for time2 in as_of.elements() {
94                        // TODO: Use `join_assign` if we ever use a timestamp with allocations.
95                        if let Some(time) = threshold.results_in(&time1.join(time2)) {
96                            upper.insert(time);
97                        }
98                    }
99                }
100
101                input.for_each_time(|time, data| {
102                    let mut session = output.session_with_builder(&time);
103                    for data in data {
104                        let borrowed = data.borrow();
105
106                        // Pass through data about to be revealed, and retain the
107                        // index of everything the chain has to hold. Only the
108                        // retained records need ordering, and in steady state the
109                        // pass-through share is the larger one.
110                        permutation.clear();
111                        for index in 0..borrowed.len() {
112                            let update = borrowed.get(index);
113                            time_buf.copy_from(update.1);
114                            if upper.less_equal(&time_buf) {
115                                permutation.push(index);
116                            } else {
117                                session.give(update);
118                            }
119                        }
120
121                        // Order the retained records by time so each bucket's
122                        // records land contiguously below. Sorting indices keeps
123                        // the records in place.
124                        permutation.sort_unstable_by_key(|index| borrowed.get(*index).1);
125
126                        // The range `buffer`'s contents belong to, `None` while empty.
127                        let mut buffered_range = None;
128                        for index in permutation.drain(..) {
129                            let update = borrowed.get(index);
130                            time_buf.copy_from(update.1);
131
132                            // Ship the buffer whenever the bucket changes, which
133                            // the time order makes a single transition per bucket.
134                            let contained = match &buffered_range {
135                                Some(range) => BucketRange::contains(range, &time_buf),
136                                None => false,
137                            };
138                            if !contained {
139                                if let Some(range) = buffered_range.take() {
140                                    let bucket = chain.find_mut(&range.start).expect("Must exist");
141                                    bucket.push_container(&mut buffer);
142                                }
143                                buffered_range =
144                                    Some(chain.range_of(&time_buf).expect("Must exist"));
145                            }
146                            buffer.push_into(update);
147                        }
148
149                        // Handle leftover data in the buffer.
150                        if let Some(range) = buffered_range.take() {
151                            let bucket = chain.find_mut(&range.start).expect("Must exist");
152                            bucket.push_container(&mut buffer);
153                        }
154                    }
155                });
156
157                // Check for data that is ready to be revealed.
158                let peeled = chain.peel(upper.borrow());
159                if let Some(cap) = cap.as_ref() {
160                    let mut session = output.session_with_builder(cap);
161                    // The chain hands back `Column` chunks already in the output's
162                    // shape, so each one moves as a container.
163                    for mut chunk in peeled.into_iter().flat_map(|x| x.done()) {
164                        session.give_container(&mut chunk);
165                    }
166                } else {
167                    // If we don't have a cap, we should not have any data to reveal.
168                    assert!(
169                        peeled
170                            .into_iter()
171                            .flat_map(|x| x.done())
172                            .all(|chunk| chunk.record_count() == 0),
173                        "Unexpected data revealed without a cap."
174                    );
175                }
176
177                // Downgrade the cap to the current input frontier.
178                if frontier.is_empty() || upper.is_empty() {
179                    cap = None;
180                } else if let Some(cap) = cap.as_mut() {
181                    // TODO: This assumes that the time is total ordered.
182                    cap.downgrade(&upper[0]);
183                }
184
185                // Maintain the bucket chain by restoring it with fuel.
186                let mut fuel = 1_000_000;
187                chain.restore(&mut fuel);
188                if fuel <= 0 {
189                    // If we run out of fuel, we activate the operator to continue processing.
190                    activator.activate();
191                }
192            }
193        })
194    }
195}
196
197/// Implementation for `Vec` streams in scopes where timestamps define a total order.
198///
199/// A caller whose consumer wants owned records keeps a `Vec`-native operator, because
200/// staging the whole stream through a column would copy every pass-through record and
201/// allocate it again on the way out. Only records that enter the chain are encoded, which
202/// they were anyway: the chain's batcher is columnar. The reduce key-value path is the one
203/// such caller, and this implementation goes away once its consumer reads columns.
204impl<'scope, T, D> TemporalBucketing<'scope, T> for StreamVec<'scope, T, (D, T, mz_repr::Diff)>
205where
206    T: Timestamp + Default + ExchangeData + MzData + BucketTimestamp + TotalOrder + Lattice,
207    D: ExchangeData + MzData + Ord + Clone + std::fmt::Debug + Hashable,
208    for<'a> <(D, T, mz_repr::Diff) as Columnar>::Container: Push<&'a (D, T, mz_repr::Diff)>,
209{
210    fn bucket(self, as_of: Antichain<T>, threshold: T::Summary) -> Self {
211        let scope = self.scope();
212        let logger = scope
213            .worker()
214            .logger_for("differential/arrange")
215            .map(Into::into);
216
217        let pact = Exchange::new(|(d, _, _): &(D, T, mz_repr::Diff)| d.hashed().into());
218        self.unary_frontier::<CapacityContainerBuilder<Vec<(D, T, mz_repr::Diff)>>, _, _, _>(
219            pact,
220            "Temporal delay",
221            |cap, info| {
222                let mut chain = BucketChain::new(MergeBatcherWrapper::new(logger, info.global_id));
223                let activator = scope.activator_for(info.address);
224
225                // Cap tracking the lower bound of potentially outstanding data.
226                let mut cap = Some(cap);
227
228                // Staging column for the records of one bucket. The chain's batcher is
229                // columnar, so a stored record is encoded either way.
230                let mut buffer: Column<(D, T, mz_repr::Diff)> = Default::default();
231
232                move |(input, frontier), output| {
233                    // The upper frontier is the join of the input frontier and the `as_of`
234                    // frontier, with the `threshold` summary applied to it.
235                    let mut upper = Antichain::new();
236                    for time1 in &frontier.frontier() {
237                        for time2 in as_of.elements() {
238                            // TODO: Use `join_assign` if we ever use a timestamp with allocations.
239                            if let Some(time) = threshold.results_in(&time1.join(time2)) {
240                                upper.insert(time);
241                            }
242                        }
243                    }
244
245                    input.for_each_time(|time, data| {
246                        let mut session = output.session_with_builder(&time);
247                        for data in data {
248                            // Skip data that is about to be revealed.
249                            let pass_through =
250                                data.extract_if(.., |(_, t, _)| !upper.less_equal(t));
251                            session.give_iterator(pass_through);
252
253                            // Sort data by time, then drain it into a buffer that contains data
254                            // for a single bucket. We scan the data for ranges of time that fall
255                            // into the same bucket so we can push batches of data at once.
256                            data.sort_unstable_by(|(_, t, _), (_, t2, _)| t.cmp(t2));
257
258                            let mut drain = data.drain(..);
259                            if let Some(update) = drain.next() {
260                                let mut range = chain.range_of(&update.1).expect("Must exist");
261                                buffer.push_into(&update);
262                                for update in drain {
263                                    // If we have a range, check if the time is not within it.
264                                    if !range.contains(&update.1) {
265                                        // If the time is outside the range, push the current
266                                        // buffer to the chain and reset the range.
267                                        if !buffer.is_empty() {
268                                            let bucket =
269                                                chain.find_mut(&range.start).expect("Must exist");
270                                            bucket.push_container(&mut buffer);
271                                        }
272                                        range = chain.range_of(&update.1).expect("Must exist");
273                                    }
274                                    buffer.push_into(&update);
275                                }
276
277                                // Handle leftover data in the buffer.
278                                if !buffer.is_empty() {
279                                    let bucket = chain.find_mut(&range.start).expect("Must exist");
280                                    bucket.push_container(&mut buffer);
281                                }
282                            }
283                        }
284                    });
285
286                    // Check for data that is ready to be revealed.
287                    let peeled = chain.peel(upper.borrow());
288                    if let Some(cap) = cap.as_ref() {
289                        let mut session = output.session_with_builder(cap);
290                        for chunk in peeled.into_iter().flat_map(|x| x.done()) {
291                            session.give_iterator(
292                                chunk
293                                    .borrow()
294                                    .into_index_iter()
295                                    .map(<(D, T, mz_repr::Diff)>::into_owned),
296                            );
297                        }
298                    } else {
299                        // If we don't have a cap, we should not have any data to reveal.
300                        assert!(
301                            peeled
302                                .into_iter()
303                                .flat_map(|x| x.done())
304                                .all(|chunk| chunk.record_count() == 0),
305                            "Unexpected data revealed without a cap."
306                        );
307                    }
308
309                    // Downgrade the cap to the current input frontier.
310                    if frontier.is_empty() || upper.is_empty() {
311                        cap = None;
312                    } else if let Some(cap) = cap.as_mut() {
313                        // TODO: This assumes that the time is total ordered.
314                        cap.downgrade(&upper[0]);
315                    }
316
317                    // Maintain the bucket chain by restoring it with fuel.
318                    let mut fuel = 1_000_000;
319                    chain.restore(&mut fuel);
320                    if fuel <= 0 {
321                        // If we run out of fuel, we activate the operator to continue processing.
322                        activator.activate();
323                    }
324                }
325            },
326        )
327    }
328}
329
330/// A wrapper around [`ColumnMergeBatcher`] that implements the bucketing API.
331///
332/// This is the same columnar-native merge batcher (`Col2ValPagedBatcher`) the
333/// default arrangement uses, so the bucket chain and arrangements share a single
334/// merge-batcher implementation. The batcher consumes pre-chunked, consolidated
335/// [`Column`] input, so this wrapper carries a [`ColumnChunker`] that sorts and
336/// consolidates the input columns into the chunks the batcher consumes.
337struct MergeBatcherWrapper<D, T, R>
338where
339    D: MzData + Ord + Clone,
340    T: MzData + Ord + PartialOrder + Clone,
341    R: MzData + Semigroup + Default,
342{
343    logger: Option<differential_dataflow::logging::Logger>,
344    operator_id: usize,
345    chunker: ColumnChunker<(D, T, R)>,
346    inner: ColumnMergeBatcher<D, T, R>,
347}
348
349impl<D, T, R> MergeBatcherWrapper<D, T, R>
350where
351    D: MzData + Ord + Clone + 'static,
352    T: MzData + Ord + PartialOrder + Clone + Default + Timestamp,
353    R: MzData + Semigroup + Default + 'static + for<'a> Semigroup<columnar::Ref<'a, R>>,
354    for<'a> columnar::Ref<'a, R>: Ord,
355    for<'a> <D as Columnar>::Container: Push<columnar::Ref<'a, D>>,
356    for<'a> <T as Columnar>::Container: Push<columnar::Ref<'a, T>>,
357    for<'a> <R as Columnar>::Container: Push<&'a R>,
358    for<'a> <(D, T, R) as Columnar>::Container: Push<&'a (D, T, R)>,
359{
360    /// Construct a new `MergeBatcherWrapper` with the given logger and operator ID.
361    fn new(logger: Option<differential_dataflow::logging::Logger>, operator_id: usize) -> Self {
362        Self {
363            logger: logger.clone(),
364            operator_id,
365            chunker: ColumnChunker::default(),
366            inner: ColumnMergeBatcher::new(logger, operator_id),
367        }
368    }
369
370    /// Consolidate `buffer` through the chunker and feed any complete chunks to
371    /// the batcher. Leaves `buffer` empty, retaining its allocation.
372    fn push_container(&mut self, buffer: &mut Column<(D, T, R)>) {
373        use timely::container::{ContainerBuilder as _, PushInto as _};
374        if buffer.is_empty() {
375            return;
376        }
377        self.chunker.push_into(buffer);
378        buffer.clear();
379        while let Some(chunk) = self.chunker.extract() {
380            self.inner.push_into(std::mem::take(chunk));
381        }
382    }
383
384    /// Flush any partial chunk still held by the chunker into the batcher.
385    fn flush(&mut self) {
386        use timely::container::ContainerBuilder as _;
387        while let Some(chunk) = self.chunker.finish() {
388            self.inner.push_into(std::mem::take(chunk));
389        }
390    }
391
392    /// Reveal the contents of the merge batcher, returning a vector of `Column` chunks.
393    fn done(mut self) -> Vec<Column<(D, T, R)>> {
394        self.flush();
395        let (chain, _description) = self.inner.seal(Antichain::new());
396        chain
397    }
398}
399
400impl<D, T, R> Bucket for MergeBatcherWrapper<D, T, R>
401where
402    D: MzData + Ord + Clone + 'static,
403    T: MzData + Ord + PartialOrder + Clone + Default + 'static + BucketTimestamp,
404    R: MzData + Semigroup + Default + 'static + for<'a> Semigroup<columnar::Ref<'a, R>>,
405    for<'a> columnar::Ref<'a, R>: Ord,
406    for<'a> <D as Columnar>::Container: Push<columnar::Ref<'a, D>>,
407    for<'a> <T as Columnar>::Container: Push<columnar::Ref<'a, T>>,
408    for<'a> <R as Columnar>::Container: Push<&'a R>,
409    for<'a> <(D, T, R) as Columnar>::Container: Push<&'a (D, T, R)>,
410{
411    type Timestamp = T;
412
413    fn split(mut self, timestamp: &Self::Timestamp, fuel: &mut i64) -> (Self, Self) {
414        // Re-chunks the sealed chunks into the lower batcher rather than splitting the
415        // batcher's chains in place, so the chunker sorts and re-pushes every record,
416        // which is what the per-record `fuel` charge below accounts for. No record is
417        // reconstituted as an owned tuple on the way.
418        //
419        // TODO: Split the batcher's chains directly without re-chunking.
420        self.flush();
421        let upper = Antichain::from_elem(timestamp.clone());
422        let mut lower = Self::new(self.logger.clone(), self.operator_id);
423        let (chain, _description) = self.inner.seal(upper);
424        for mut chunk in chain {
425            *fuel = fuel.saturating_sub(chunk.record_count());
426            lower.push_container(&mut chunk);
427        }
428        (lower, self)
429    }
430}