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}