From cc495df1233c1b77f52286fbf70e689adf4a5c3a Mon Sep 17 00:00:00 2001 From: Frank McSherry Date: Wed, 8 Jul 2026 13:04:54 -0400 Subject: [PATCH 1/5] join: reshape JoinTactic to an iterator of output containers Replace the two-method JoinTactic (defer + fuel-metered work) with a single `prep(input0, input1, fresh, meet) -> Box>`. The tactic now maps two batch lists to an iterator of output containers and holds neither capabilities nor a fuel budget, mirroring ReduceTactic's data-to-data division of labor. The driver owns the two per-direction queues, pairs each iterator with its shipping capability, pulls under a split fuel budget, ships each yielded container, and drops a unit when its iterator goes dry. "Work remains" becomes driver-observable (an iterator has yet to yield None) rather than a fuel-sign protocol the tactic must self-report. Join closures now push into a bare `&mut EffortBuilder` rather than a timely `JoinSession`. The session is bound to a live operator output and is not locally constructible, so it could not back a self-contained iterator; the arbitrary-container capability #478 wanted comes from the ContainerBuilder, not the session, so a bare builder suffices. `JoinSession` is removed. The cursor tactic shares its logic closure across units via Rc> (each unit is a 'static iterator and cannot borrow the tactic), preserving the single-mutable-state semantics of one closure threaded through every match. Deferred becomes DeferredIter: next() plays the same merge-join loop forward, suspending at container boundaries instead of under a fuel budget. Open for review: - EffortBuilder's record counter is now vestigial; the type reduces to a bare give-providing sink, or could be removed for push_into on CB directly. - DeferredIter::next uses mem::take on extract (needs Container: Default), stealing the builder's recycled allocation; a stash-swap would avoid it. - logic's public sink type changed (JoinSession -> EffortBuilder); Mz exposure unverified. Co-Authored-By: Claude Opus 4.8 (1M context) --- differential-dataflow/src/operators/join.rs | 335 +++++++++++--------- 1 file changed, 184 insertions(+), 151 deletions(-) diff --git a/differential-dataflow/src/operators/join.rs b/differential-dataflow/src/operators/join.rs index 72b2085e9..efe27be81 100644 --- a/differential-dataflow/src/operators/join.rs +++ b/differential-dataflow/src/operators/join.rs @@ -4,29 +4,43 @@ //! the multiplication distributes over addition. That is, we will repeatedly evaluate (a + b) * c as (a * c) //! + (b * c), and if this is not equal to the former term, little is known about the actual output. use std::cmp::Ordering; +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; use timely::{Accountable, ContainerBuilder}; use timely::container::PushInto; use timely::order::PartialOrder; use timely::progress::Timestamp; use timely::dataflow::Stream; -use timely::dataflow::operators::generic::{Operator, OutputBuilderSession, Session}; +use timely::dataflow::operators::generic::{Operator, OutputBuilderSession}; use timely::dataflow::channels::pact::Pipeline; use timely::dataflow::operators::Capability; use crate::lattice::Lattice; use crate::operators::arrange::Arranged; use crate::trace::{BatchCursor, BatchDiff, BatchKey, BatchReader, BatchVal, Cursor, Navigable, TraceReader}; -use crate::trace::cursor::{cursor_list, CursorList}; +use crate::trace::cursor::cursor_list; use crate::operators::ValueHistory; -/// The session passed to join closures. -pub type JoinSession<'a, 'b, T, CB, CT> = Session<'a, 'b, T, EffortBuilder, CT>; - -/// A container builder that tracks the length of outputs to estimate the effort of join closures. +/// The sink presented to join closures: a thin [`ContainerBuilder`] wrapper providing [`give`](Self::give). +/// +/// The wrapped `usize` cell once counted built records, so the driver could meter fuel from a closure's +/// output volume. The driver now meters fuel from the record counts of the containers each prepared unit +/// yields, so the counter is no longer read. This leaves the type a candidate for reduction to a bare +/// sink, or removal in favor of pushing into `CB` directly (at the cost of the `give` ergonomics that +/// closures rely on). Left in place pending review. #[derive(Default, Debug)] pub struct EffortBuilder(pub std::cell::Cell, pub CB); +impl EffortBuilder { + /// Push one output record into the builder. This is the sink presented to join closures. + #[inline] + pub fn give(&mut self, item: D) where Self: PushInto { + self.push_into(item); + } +} + impl timely::container::ContainerBuilder for EffortBuilder { type Container = CB::Container; @@ -53,22 +67,21 @@ impl, D> PushInto for EffortBuilder { } /// A type that can manage the joining of lists of batches. +/// +/// A tactic maps two lists of batches to an iterator of output containers; it holds neither +/// capabilities nor a fuel budget. The driver ([`join_with_tactic`]) pairs the returned iterator with +/// the capability under which to ship its output, pulls it under a fuel budget, ships each yielded +/// container, and drops the unit when the iterator goes dry. The iterator is the suspension mechanism: +/// a container the driver is free to stop reading. Because "work remains" is just "the iterator has +/// not yet yielded `None`," dryness is driver-observable rather than a protocol the tactic reports. pub(crate) trait JoinTactic, CB: ContainerBuilder> { - /// Prepare for work the join of two lists of corresponding batches, against a sufficient capability. + /// Prepare the join of two lists of corresponding batches into an iterator of output containers. /// /// `fresh` names which input contributed the freshly-arrived batch; its times all lie at or beyond - /// the capability, so a tactic need not advance that side by the capability's meet. - fn defer(&mut self, input0: Vec, input1: Vec, fresh: Fresh, capability: Capability); - /// Perform an amount of work that just barely exceeds `fuel`, which is decremented. - /// - /// **Fuel protocol.** On return, `fuel` is non-negative *iff* all outstanding work is exhausted, - /// and left negative exactly when work remains. The driver ([`join_with_tactic`]) reschedules the - /// operator iff `fuel < 0`, so returning non-negative with work still queued silently drops it - /// (and returning negative with nothing to do spins). The protocol is not driver-checkable — the - /// work-remaining state is tactic-internal — so derive the sign *from* that state rather than - /// tracking it separately, and it holds by construction (as the in-tree tactic does, setting - /// `fuel` from whether its queues are empty). - fn work(&mut self, fuel: &mut isize, output: &mut OutputBuilderSession>); + /// `meet`, so a tactic need not advance that side by `meet`. `meet` is the time of the capability + /// the driver will ship this unit's output under — the lower envelope at which output is produced, + /// which the tactic may use to consolidate the accumulated side before the cross-product. + fn prep(&mut self, input0: Vec, input1: Vec, fresh: Fresh, meet: B0::Time) -> Box>; } /// Which input contributed the freshly-arrived batch of a deferred join unit. @@ -102,8 +115,8 @@ where Tr2: TraceReader+'static, BatchCursor: Cursor