Skip to main content

differential_dataflow/operators/
join.rs

1//! Match pairs of records based on a key.
2//!
3//! The various `join` implementations require that the units of each collection can be multiplied, and that
4//! the multiplication distributes over addition. That is, we will repeatedly evaluate (a + b) * c as (a * c)
5//! + (b * c), and if this is not equal to the former term, little is known about the actual output.
6use std::collections::VecDeque;
7
8use timely::Container;
9use timely::container::NoopBuilder;
10use timely::order::PartialOrder;
11use timely::progress::Timestamp;
12use timely::dataflow::Stream;
13use timely::dataflow::operators::generic::{Operator, OutputBuilderSession};
14use timely::dataflow::channels::pact::Pipeline;
15use timely::dataflow::operators::CapabilitySet;
16
17use crate::lattice::Lattice;
18use crate::operators::arrange::Arranged;
19use crate::trace::TraceReader;
20
21/// A type that can manage the joining of lists of batches.
22///
23/// The trait is parameterized by the output container `C`, not by the builder that assembles it: a tactic
24/// yields finished containers, and how it produces them (pushing records into a [`timely::ContainerBuilder`], or
25/// otherwise) is its own concern.
26pub trait JoinTactic<T, B0, B1, C> {
27    /// Prepare the join of two lists of batches into an iterator of output containers.
28    ///
29    /// The supplied `fresh` and `meet` indicate respectively which input is "novel", and should drive the
30    /// join, as well as a lower bound on that input's times, so that the other input can be loaded
31    /// compacted.
32    fn prep(&mut self, input0: Vec<B0>, input1: Vec<B1>, fresh: Fresh, meet: T) -> Box<dyn Iterator<Item = C>>;
33}
34
35/// Which input contributed the freshly-arrived batch of a deferred join unit.
36///
37/// The fresh batch's times all lie at or beyond the capability, so its side is not advanced by the
38/// capability's meet; the opposing accumulated trace is. The marker also selects which queue a unit
39/// joins, so a burst on one input cannot starve the other.
40pub enum Fresh {
41    /// The first input (`B0`) contributed the fresh batch.
42    Input0,
43    /// The second input (`B1`) contributed the fresh batch.
44    Input1,
45}
46
47pub use crate::operators::cursor::join::join_traces;
48
49/// Drives an equijoin of two traces using a supplied [`JoinTactic`].
50///
51/// This is the general join operator: it does the dataflow plumbing (frontiers, capabilities, trace
52/// compaction) and routes the per-batch work through the tactic. It requires only `TraceReader` of its
53/// inputs, never `Navigable`: it extracts trace batches via `spans_through`, and building cursors over
54/// them (if that is how the join proceeds) is the tactic's concern.
55pub fn join_with_tactic<'scope, Tr1, Tr2, T, C>(arranged1: Arranged<'scope, Tr1>, arranged2: Arranged<'scope, Tr2>, name: &str, mut tactic: T) -> Stream<'scope, Tr1::Time, C>
56where
57    Tr1: TraceReader+'static,
58    Tr2: TraceReader<Time = Tr1::Time>+'static,
59    T: JoinTactic<Tr1::Time, Tr1::Batch, Tr2::Batch, C>+'static,
60    C: Container + 'static,
61{
62    // Rename traces for symmetry from here on out.
63    let mut trace1 = arranged1.trace;
64    let mut trace2 = arranged2.trace;
65
66    let scope = arranged1.stream.scope();
67    arranged1.stream.binary_frontier(arranged2.stream, Pipeline, Pipeline, name, move |capability, info| {
68
69        // Acquire an activator to reschedule the operator when it has unfinished work.
70        use timely::scheduling::Activator;
71        let activations = scope.activations();
72        let activator = Activator::new(info.address, activations);
73
74        // Our initial invariants are that for each trace, physical compaction is less or equal the trace's upper bound.
75        // These invariants ensure that we can reference observed batch frontiers from `_start_upper` onward, as long as
76        // we maintain our physical compaction capabilities appropriately. These assertions are tested as we load up the
77        // initial work for the two traces, and before the operator is constructed.
78
79        // Acknowledged frontier for each input.
80        // These two are used exclusively to track batch boundaries on which we may want/need to call `spans_through`.
81        // They will drive our physical compaction of each trace, and we want to maintain at all times that each is beyond
82        // the physical compaction frontier of their corresponding trace.
83        // Should we ever *drop* a trace, these are 1. much harder to maintain correctly, but 2. no longer used.
84        use timely::progress::frontier::Antichain;
85        let mut acknowledged1 = Antichain::from_elem(Tr1::Time::minimum());
86        let mut acknowledged2 = Antichain::from_elem(Tr1::Time::minimum());
87
88        // Deferred work, as `(capability, iterator)` pairs bucketed by which input carried the fresh
89        // batch (so a burst on one input cannot starve the other). The driver owns the capabilities and
90        // the fuel budget; each iterator, prepared by the tactic, yields the output containers to ship
91        // under its paired capability, and is dropped once it goes dry.
92        let mut todo0: VecDeque<(CapabilitySet<Tr1::Time>, Box<dyn Iterator<Item = C>>)> = VecDeque::new();
93        let mut todo1: VecDeque<(CapabilitySet<Tr1::Time>, Box<dyn Iterator<Item = C>>)> = VecDeque::new();
94
95        // We'll unload the initial batches here, to put ourselves in a less non-deterministic state to start.
96        trace1.map_spans(|batch1| {
97            acknowledged1.clone_from(batch1.upper());
98            // No `todo1` work here, because we haven't accepted anything into `batches2` yet.
99            // It is effectively "empty", because we choose to drain `trace1` before `trace2`.
100            // Once we start streaming batches in, we will need to respond to new batches from
101            // `input1` with logic that would have otherwise been here. Check out the next loop
102            // for the structure.
103        });
104        // At this point, `ack1` should exactly equal `trace1.read_upper()`, as they are both determined by
105        // iterating through batches and capturing the upper bound. This is a great moment to assert that
106        // `trace1`'s physical compaction frontier is before the frontier of completed times in `trace1`.
107        // TODO: in the case that this does not hold, instead "upgrade" the physical compaction frontier.
108        assert!(PartialOrder::less_equal(&trace1.get_physical_compaction(), &acknowledged1.borrow()));
109
110        // We capture batch2's batches first and establish work second to avoid taking a `RefCell` lock
111        // on both traces at the same time, as they could be the same trace and this would panic.
112        let mut batch2_list = Vec::new();
113        trace2.map_spans(|batch2| {
114            acknowledged2.clone_from(batch2.upper());
115            batch2_list.push(batch2.clone());
116        });
117        // At this point, `ack2` should exactly equal `trace2.read_upper()`, as they are both determined by
118        // iterating through batches and capturing the upper bound. This is a great moment to assert that
119        // `trace2`'s physical compaction frontier is before the frontier of completed times in `trace2`.
120        // TODO: in the case that this does not hold, instead "upgrade" the physical compaction frontier.
121        assert!(PartialOrder::less_equal(&trace2.get_physical_compaction(), &acknowledged2.borrow()));
122
123        // Batches wholly at or before these frontiers were joined by the start-up loading
124        // above; batches arriving on the input streams are ignored up to them. Beyond them,
125        // every non-empty arriving batch must be joined, even when `acknowledged` has been
126        // advanced past it: `advance_upper` consults the shared trace, whose merges may have
127        // consolidated an in-flight batch's updates away (e.g. an add/remove pair collapsing
128        // once logical compaction equates their times). The trace's emptiness there is valid
129        // only for readers at or beyond the compaction frontier, while our consumers may read
130        // finer times; the raw batch still owes them its updates. (#801)
131        let preload_upper1 = acknowledged1.clone();
132        let preload_upper2 = acknowledged2.clone();
133
134        // Load up deferred work joining each captured `trace2` batch against `trace1`.
135        for batch2 in batch2_list.into_iter() {
136            // Empty batches carry no updates, and have nothing to join.
137            let Some(updates2) = batch2.inner else { continue };
138            // It is safe to ask for `ack1` because we have confirmed it to be in advance of `distinguish_since`.
139            let trace1_storage = trace1.batches_through(acknowledged1.borrow()).unwrap();
140            // We could downgrade the capability here, but doing so is a bit complicated mathematically.
141            // TODO: downgrade the capability by searching out the one time in `batch2.lower()` and not
142            // in `batch2.upper()`. Only necessary for non-empty batches, as empty batches may not have
143            // that property.
144            let work = tactic.prep(trace1_storage, vec![updates2], Fresh::Input1, capability.time().clone());
145            todo1.push_back((CapabilitySet::from_elem(capability.clone()), work));
146        }
147
148        // Droppable handles to shared trace data structures.
149        let mut trace1_option = Some(trace1);
150        let mut trace2_option = Some(trace2);
151
152        move |(input1, frontier1), (input2, frontier2), output| {
153
154            // 1. Consuming input.
155            //
156            // The join computation repeatedly accepts batches of updates from each of its inputs.
157            //
158            // For each accepted batch, it prepares a work-item to join the batch against previously "accepted"
159            // updates from its other input. It is important to track which updates have been accepted, because
160            // we use a shared trace and there may be updates present that are in advance of this accepted bound.
161            //
162            // Batches are accepted: 1. in bulk at start-up (above), 2. as we observe them in the input stream,
163            // and 3. if the trace can confirm a region of empty space directly following our accepted bound.
164            // This last case is a consequence of our inability to transmit empty batches, as they may be formed
165            // in the absence of timely dataflow capabilities.
166
167            // Drain input 1, prepare work.
168            input1.for_each(|capability, data| {
169                // This test *should* always pass, as we only drop a trace in response to the other input emptying.
170                if let Some(ref mut trace2) = trace2_option {
171                    let capability = capability.retain_stamp(0);
172                    // The lattice meet of the stamp's elements lower bounds all output
173                    // times this batch can produce.
174                    let meet = capability.iter().map(|c| c.time().clone()).reduce(|a, b| a.meet(&b));
175                    for batch1 in data.drain(..) {
176                        // An arriving batch must lie wholly on one side of the preload boundary,
177                        // and wholly on one side of `acknowledged1`: both frontiers are drawn from
178                        // the lattice of stream batch boundaries (received uppers, and uppers of
179                        // trace merges of whole stream batches). A batch spanning the former would
180                        // be partially double-processed; one spanning the latter mis-accounted.
181                        assert!(
182                            PartialOrder::less_equal(batch1.upper(), &preload_upper1) ||
183                            PartialOrder::less_equal(&preload_upper1, batch1.lower()),
184                            "batch spans the preload boundary",
185                        );
186                        assert!(
187                            PartialOrder::less_equal(&acknowledged1, batch1.lower()) ||
188                            PartialOrder::less_equal(batch1.upper(), &acknowledged1),
189                            "batch spans the acknowledged frontier",
190                        );
191
192                        // Ignore any pre-loaded data, which was joined at start-up. Note that this
193                        // is a test against the preload boundary, not against `acknowledged1`: the
194                        // latter can be advanced past an in-flight batch by `advance_upper`, when
195                        // trace merges consolidate the batch's updates away, and such a batch must
196                        // still be joined (its updates remain real at times finer than the trace's
197                        // compaction frontier, and no other work item has accounted for them).
198                        if !PartialOrder::less_equal(batch1.upper(), &preload_upper1) {
199                            if let Some(updates1) = batch1.inner.clone() {
200                                // It is safe to ask for `ack2` as we validated that it was at least `get_physical_compaction()`
201                                // at start-up, and have held back physical compaction ever since.
202                                let trace2_storage = trace2.batches_through(acknowledged2.borrow()).unwrap();
203                                let work = tactic.prep(vec![updates1], trace2_storage, Fresh::Input0, meet.clone().expect("non-empty stamp"));
204                                todo0.push_back((capability.clone(), work));
205                            }
206
207                            // To update `acknowledged1` we might presume that `batch1.lower` should equal it, but we
208                            // may have skipped over empty batches. Still, the batches are in-order, and we should be
209                            // able to just assume the most recent `batch1.upper`, unless `advance_upper` has already
210                            // moved `acknowledged1` past this batch, in which case we keep the further frontier.
211                            if PartialOrder::less_equal(&acknowledged1, batch1.lower()) {
212                                debug_assert!(PartialOrder::less_equal(&acknowledged1, batch1.upper()));
213                                acknowledged1.clone_from(batch1.upper());
214                            }
215                        }
216                    }
217                }
218                else { panic!("`trace2_option` dropped before `input1` emptied!"); }
219            });
220
221            // Drain input 2, prepare work.
222            input2.for_each(|capability, data| {
223                // This test *should* always pass, as we only drop a trace in response to the other input emptying.
224                if let Some(ref mut trace1) = trace1_option {
225                    let capability = capability.retain_stamp(0);
226                    // The lattice meet of the stamp's elements lower bounds all output
227                    // times this batch can produce.
228                    let meet = capability.iter().map(|c| c.time().clone()).reduce(|a, b| a.meet(&b));
229                    for batch2 in data.drain(..) {
230                        // An arriving batch must lie wholly on one side of the preload boundary,
231                        // and wholly on one side of `acknowledged2`: both frontiers are drawn from
232                        // the lattice of stream batch boundaries (received uppers, and uppers of
233                        // trace merges of whole stream batches). A batch spanning the former would
234                        // be partially double-processed; one spanning the latter mis-accounted.
235                        assert!(
236                            PartialOrder::less_equal(batch2.upper(), &preload_upper2) ||
237                            PartialOrder::less_equal(&preload_upper2, batch2.lower()),
238                            "batch spans the preload boundary",
239                        );
240                        assert!(
241                            PartialOrder::less_equal(&acknowledged2, batch2.lower()) ||
242                            PartialOrder::less_equal(batch2.upper(), &acknowledged2),
243                            "batch spans the acknowledged frontier",
244                        );
245
246                        // Ignore any pre-loaded data, which was joined at start-up. Note that this
247                        // is a test against the preload boundary, not against `acknowledged2`: the
248                        // latter can be advanced past an in-flight batch by `advance_upper`, when
249                        // trace merges consolidate the batch's updates away, and such a batch must
250                        // still be joined (its updates remain real at times finer than the trace's
251                        // compaction frontier, and no other work item has accounted for them).
252                        if !PartialOrder::less_equal(batch2.upper(), &preload_upper2) {
253                            if let Some(updates2) = batch2.inner.clone() {
254                                // It is safe to ask for `ack1` as we validated that it was at least `get_physical_compaction()`
255                                // at start-up, and have held back physical compaction ever since.
256                                let trace1_storage = trace1.batches_through(acknowledged1.borrow()).unwrap();
257                                let work = tactic.prep(trace1_storage, vec![updates2], Fresh::Input1, meet.clone().expect("non-empty stamp"));
258                                todo1.push_back((capability.clone(), work));
259                            }
260
261                            // To update `acknowledged2` we might presume that `batch2.lower` should equal it, but we
262                            // may have skipped over empty batches. Still, the batches are in-order, and we should be
263                            // able to just assume the most recent `batch2.upper`, unless `advance_upper` has already
264                            // moved `acknowledged2` past this batch, in which case we keep the further frontier.
265                            if PartialOrder::less_equal(&acknowledged2, batch2.lower()) {
266                                debug_assert!(PartialOrder::less_equal(&acknowledged2, batch2.upper()));
267                                acknowledged2.clone_from(batch2.upper());
268                            }
269                        }
270                    }
271                }
272                else { panic!("`trace1_option` dropped before `input2` emptied!"); }
273            });
274
275            // Advance acknowledged frontiers through any empty regions that we may not receive as batches.
276            if let Some(trace1) = trace1_option.as_mut() {
277                trace1.advance_upper(&mut acknowledged1);
278            }
279            if let Some(trace2) = trace2_option.as_mut() {
280                trace2.advance_upper(&mut acknowledged2);
281            }
282
283            // 2. Join computation.
284            //
285            // For each of the inputs, we do some amount of work (measured in terms of number
286            // of output records produced). This is meant to yield control to allow downstream
287            // operators to consume and reduce the output, but it it also means to provide some
288            // degree of responsiveness. There is a potential risk here that if we fall behind
289            // then the increasing queues hold back physical compaction of the underlying traces
290            // which results in unintentionally quadratic processing time (each batch of either
291            // input must scan all batches from the other input).
292
293            // Perform some amount of outstanding work by pulling the deferred iterators and shipping the
294            // containers they yield. Each direction drains against its own half of the budget, so a burst
295            // on one input cannot starve the other. We reschedule the operator whenever any work remains,
296            // which is observable directly: an iterator has yet to yield `None`. The budget is split from
297            // `2_000_000` to preserve the historical `1_000_000` of progress per input each activation.
298            // The driver only ships finished containers (`give_container`), never pushing records, so it
299            // pins the operator output to `NoopBuilder<C>` — the builder for exactly this "containers ready
300            // to go" case, which is a `ContainerBuilder` for any `C` without further bounds.
301            let output: &mut OutputBuilderSession<'_, Tr1::Time, NoopBuilder<C>> = output;
302            let mut drain = |queue: &mut VecDeque<(CapabilitySet<Tr1::Time>, Box<dyn Iterator<Item = C>>)>, mut fuel: isize| {
303                while fuel >= 0 {
304                    let Some((capability, work)) = queue.front_mut() else { break };
305                    match work.next() {
306                        Some(mut container) => {
307                            fuel -= container.record_count() as isize;
308                            output.session_with_builder(&*capability).give_container(&mut container);
309                        }
310                        None => { queue.pop_front(); }
311                    }
312                }
313            };
314            let fuel = 2_000_000;
315            drain(&mut todo0, fuel / 2);
316            drain(&mut todo1, fuel / 2);
317            if !todo0.is_empty() || !todo1.is_empty() {
318                activator.activate();
319            }
320
321            // 3. Trace maintenance.
322            //
323            // Importantly, we use `input.frontier()` here rather than `acknowledged` to track
324            // the progress of an input, because should we ever drop one of the traces we will
325            // lose the ability to extract information from anything other than the input.
326            // For example, if we dropped `trace2` we would not be able to use `advance_upper`
327            // to keep `acknowledged2` up to date wrt empty batches, and would hold back logical
328            // compaction of `trace1`.
329
330            // Maintain `trace1`. Drop if `input2` is empty, or advance based on future needs.
331            if let Some(trace1) = trace1_option.as_mut() {
332                if frontier2.is_empty() { trace1_option = None; }
333                else {
334                    // Allow `trace1` to compact logically up to the frontier we may yet receive,
335                    // in the opposing input (`input2`). All `input2` times will be beyond this
336                    // frontier, and joined times only need to be accurate when advanced to it.
337                    trace1.set_logical_compaction(frontier2.frontier());
338                    // Allow `trace1` to compact physically up to the upper bound of batches we
339                    // have received in its input (`input1`). We will not require a cursor that
340                    // is not beyond this bound.
341                    trace1.set_physical_compaction(acknowledged1.borrow());
342                }
343            }
344
345            // Maintain `trace2`. Drop if `input1` is empty, or advance based on future needs.
346            if let Some(trace2) = trace2_option.as_mut() {
347                if frontier1.is_empty() { trace2_option = None;}
348                else {
349                    // Allow `trace2` to compact logically up to the frontier we may yet receive,
350                    // in the opposing input (`input1`). All `input1` times will be beyond this
351                    // frontier, and joined times only need to be accurate when advanced to it.
352                    trace2.set_logical_compaction(frontier1.frontier());
353                    // Allow `trace2` to compact physically up to the upper bound of batches we
354                    // have received in its input (`input2`). We will not require a cursor that
355                    // is not beyond this bound.
356                    trace2.set_physical_compaction(acknowledged2.borrow());
357                }
358            }
359        }
360    })
361}