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}