Skip to main content

differential_dataflow/operators/
reduce.rs

1//! Applies a reduction function on records grouped by key.
2//!
3//! The `reduce` operator acts on `(key, val)` data.
4//! Records with the same key are grouped together, and a user-supplied reduction function is applied
5//! to the key and the list of values.
6//! The function is expected to populate a list of output values.
7//!
8//! The output can change at times that are joins of input times, not only at input times themselves,
9//! and the operator must determine at which times to re-evaluate the reduction. A machine-checked
10//! account of which times suffice lives in `formal/Differential/Coverage.lean`.
11
12use timely::progress::frontier::Antichain;
13use timely::progress::Timestamp;
14use timely::dataflow::operators::Operator;
15use timely::dataflow::operators::CapabilitySet;
16use timely::dataflow::channels::pact::Pipeline;
17
18use crate::operators::arrange::{Arranged, TraceAgent};
19use crate::trace::{Span, ExertionLogic, Trace, TraceReader};
20
21/// Sort and deduplicate a list. Shared by the cursor and proxy tactics, which
22/// each previously carried an identical copy.
23#[inline(never)]
24pub(crate) fn sort_dedup<T: Ord>(list: &mut Vec<T>) {
25    list.dedup();
26    list.sort();
27    list.dedup();
28}
29
30/// A type that resolves a key-wise reduction over batches arriving on the input.
31///
32/// Unlike join, reduce does not suspend: its output is at most linear in its input, so a single
33/// `retire` runs the whole `[lower, upper)` interval to completion rather than yielding under a fuel
34/// budget.
35pub trait ReduceTactic<T, B1, B2> {
36    /// Retire the interval `[lower, upper)`, producing the output batch it informs.
37    ///
38    /// It is presented with the pre-existing input batches and output batches (those before `lower`),
39    /// the new input batches, and `held`: the times the operator currently holds capabilities for. It
40    /// reasons only about times, returning the output batch to ship — `None` when the interval holds
41    /// no work at all — and the new frontier of interesting times for the operator to hold.
42    ///
43    /// # Contract
44    ///
45    /// The driver ([`reduce_with_tactic`]) relies on the following; the first is cheap to check
46    /// and is `debug_assert!`ed there.
47    ///
48    /// * **Spanning output.** The returned batch's description is exactly `[lower, upper)`. The
49    ///   driver ships it stamped with the held times not in advance of `upper`, which justify its
50    ///   contents: every update time lies at or beyond one of them (a time at or beyond only the
51    ///   remaining held times would be in advance of `upper`, and so outside the interval).
52    /// * **Frontier bounds withheld work, and collapses to empty when there is none.** The returned
53    ///   frontier must be at-or-below every time the tactic defers, so the driver knows what is safe to
54    ///   release. In particular, with no work to defer it must be the *empty* antichain. Derive it from
55    ///   the actual withheld set rather than constructing it and this holds for free; returning a
56    ///   non-empty frontier with nothing pending holds capabilities forever and **deadlocks recursive
57    ///   scopes**. (Not driver-checkable — the withheld set is tactic-internal — so tactics self-enforce.)
58    fn retire(
59        &mut self,
60        source_batches: Vec<B1>,
61        output_batches: Vec<B2>,
62        input_batches: Vec<B1>,
63        lower: &Antichain<T>,
64        upper: &Antichain<T>,
65        held: &Antichain<T>,
66    ) -> (Option<Span<T, B2>>, Antichain<T>);
67}
68
69pub use crate::operators::cursor::reduce::reduce_trace;
70
71/// Drives a key-wise reduction using a supplied [`ReduceTactic`].
72///
73/// This is the general reduce operator: it does the dataflow plumbing (frontiers, capabilities, output
74/// trace maintenance) and routes the per-interval work through the tactic. It requires only
75/// `TraceReader` of its input and `Trace` of its output, never `Navigable`: it extracts batches via
76/// `spans_through`, and building cursors over them (if that is how the reduce proceeds) is the
77/// tactic's concern.
78pub fn reduce_with_tactic<'scope, Tr1, Tr2, T>(trace: Arranged<'scope, Tr1>, name: &str, mut tactic: T) -> Arranged<'scope, TraceAgent<Tr2>>
79where
80    Tr1: TraceReader + 'static,
81    Tr2: Trace<Time = Tr1::Time> + 'static,
82    T: ReduceTactic<Tr1::Time, Tr1::Batch, Tr2::Batch> + 'static,
83{
84    let mut result_trace = None;
85
86    // fabricate a data-parallel operator that holds capabilities and consults its input frontier.
87    let stream = {
88
89        let mut source_trace = trace.trace;
90        let result_trace = &mut result_trace;
91        let scope = trace.stream.scope();
92        trace.stream.unary_frontier(Pipeline, name, move |_capability, operator_info| {
93
94            // Acquire a logger for arrange events.
95            let logger = scope.worker().logger_for::<crate::logging::DifferentialEventBuilder>("differential/arrange").map(Into::into);
96
97            let activator = Some(scope.activator_for(std::rc::Rc::clone(&operator_info.address)));
98            let mut empty = Tr2::new(operator_info.clone(), logger.clone(), activator);
99            // If there is default exert logic set, install it.
100            if let Some(exert_logic) = scope.worker().config().get::<ExertionLogic>("differential/default_exert_logic").cloned() {
101                empty.set_exert_logic(exert_logic);
102            }
103
104            let (mut output_reader, mut output_writer) = TraceAgent::new(empty, operator_info, logger);
105
106            *result_trace = Some(output_reader.clone());
107
108            // Capabilities for the lower envelope of the interesting times the operator holds.
109            let mut capabilities = CapabilitySet::<Tr1::Time>::default();
110
111            // Upper and lower frontiers for the pending input and output batches to process.
112            let mut upper_limit = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
113            let mut lower_limit = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
114            // The same, but as bounds of source trace batches. The limits above join in the input frontier,
115            // which can put them strictly inside a source batch, where the trace cannot be cut. No update lies
116            // between the two, as the input frontier rules out the times the join adds.
117            let mut source_upper = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
118            let mut source_lower = Antichain::from_elem(<Tr1::Time as Timestamp>::minimum());
119
120            move |(input, frontier), output| {
121
122                // The operator receives input batches, which it treats as contiguous and will collect and
123                // then process as one batch. It captures the input frontier from the batches, from the upstream
124                // trace, and from the input frontier, and retires the work through that interval.
125                //
126                // Reduce may retain capabilities and need to perform work and produce output at times that
127                // may not be seen in its input. The standard example is that updates at `(0, 1)` and `(1, 0)`
128                // may result in outputs at `(1, 1)` as well, even with no input at that time.
129
130                let mut batch_storage = Vec::new();
131
132                // Downgrade previous upper limit to be current lower limit.
133                lower_limit.clear();
134                lower_limit.extend(upper_limit.borrow().iter().cloned());
135                source_lower.clone_from(&source_upper);
136
137                // Drain input batches in order, capturing capabilities and the last upper.
138                input.for_each(|capability, batches| {
139                    for capability in capability.retain_stamp(0).iter() {
140                        capabilities.insert(capability.clone());
141                    }
142                    for batch in batches.drain(..) {
143                        source_upper.clone_from(batch.upper());
144                        batch_storage.push(batch);
145                    }
146                });
147
148                // Pull in any subsequent empty batches we believe to exist.
149                source_trace.advance_upper(&mut source_upper);
150                // Incorporate the input frontier guarantees as well.
151                let mut joined = Antichain::new();
152                crate::lattice::antichain_join_into(&source_upper.borrow()[..], &frontier.frontier()[..], &mut joined);
153                upper_limit = joined;
154
155                // We plan to retire the interval [lower_limit, upper_limit), which should be non-empty to proceed.
156                if upper_limit != lower_limit {
157
158                    // Acquire the pre-existing input and output batches preceding the interval. Batch handles
159                    // are cheap to clone, so we fetch them whether or not the tactic finds work to do.
160                    let source_batches = source_trace.batches_through(source_lower.borrow()).expect("failed to acquire source batches");
161                    let output_batches = output_reader.batches_through(lower_limit.borrow()).expect("failed to acquire output batches");
162
163                    // The times the operator currently holds capabilities for, as an antichain.
164                    let held: Antichain<Tr1::Time> = capabilities.iter().map(|c| c.time().clone()).collect();
165
166                    // Retire the interval. The tactic reasons only about times: it returns the output
167                    // batch to ship, if any, and the new frontier of interesting times.
168                    let (produced, new_frontier) = tactic.retire(source_batches, output_batches, batch_storage.into_iter().filter_map(|b| b.inner).collect(), &lower_limit, &upper_limit, &held);
169
170                    // Contract check (see `ReduceTactic::retire`). Cheap, debug-only.
171                    debug_assert!(
172                        produced.as_ref().is_none_or(|batch| batch.lower() == &lower_limit && batch.upper() == &upper_limit),
173                        "ReduceTactic::retire output must span [lower, upper)",
174                    );
175
176                    // Ship the batch stamped with the capabilities it retires — those not in advance of
177                    // the upper limit — and commit it to the output trace. The times are elements of
178                    // `held`, so they stay valid until we downgrade.
179                    if let Some(batch) = produced {
180                        let retiring = capabilities
181                            .iter()
182                            .filter(|c| !upper_limit.less_equal(c.time()))
183                            .cloned()
184                            .collect::<CapabilitySet<_>>();
185                        output.session(&retiring).give(batch.clone());
186                        let stamp = retiring.iter().map(|c| c.time().clone()).collect::<timely::progress::Stamp<_>>();
187                        output_writer.insert(batch, stamp);
188                    }
189
190                    // Downgrade to the frontier the tactic handed back (a no-op when it found no work).
191                    capabilities.downgrade(new_frontier);
192
193                    // ensure that observed progress is reflected in the output.
194                    output_writer.seal(upper_limit.clone());
195
196                    // We only anticipate future times in advance of `upper_limit`.
197                    source_trace.set_logical_compaction(upper_limit.borrow());
198                    output_reader.set_logical_compaction(upper_limit.borrow());
199
200                    // We will only slice the data between future batches.
201                    source_trace.set_physical_compaction(source_upper.borrow());
202                    output_reader.set_physical_compaction(upper_limit.borrow());
203                }
204
205                // Exert trace maintenance if we have been so requested.
206                output_writer.exert();
207            }
208        }
209    )
210    };
211
212    Arranged { stream, trace: result_trace.unwrap() }
213}