Skip to main content

mz_timely_util/columnar/
merge_batcher.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Merge-batcher for [`Column`] chunks with per-chunk paging.
17//!
18//! Forks the [`differential_dataflow`] merge-batcher framework so chains can
19//! hold [`PagedColumn`] entries — letting the [`ColumnPager`] page chunks
20//! out as they're produced and fetch them back lazily during merge / extract.
21//!
22//! Reuses the resident building blocks from [`super::batcher`]: the inherent
23//! `Column::merge_from` / `Column::extract` methods (per-chunk merge / split).
24//! Input consolidation happens upstream: the chunker
25//! ([`super::batcher::ColumnChunker`]) is supplied to the arrange operator
26//! separately, so this batcher receives already-consolidated [`Column`] chunks
27//! via [`PushInto`].
28//!
29//! [`differential_dataflow`]: differential_dataflow::trace::implementations::merge_batcher
30
31use std::collections::VecDeque;
32
33use columnar::{Columnar, Index, Len};
34use differential_dataflow::difference::Semigroup;
35use differential_dataflow::logging::{BatcherEvent, Logger};
36use differential_dataflow::trace::{Batcher, Description};
37use timely::Accountable;
38use timely::PartialOrder;
39use timely::container::{PushInto, SizableContainer};
40use timely::dataflow::channels::ContainerBytes;
41use timely::progress::Timestamp;
42use timely::progress::frontier::{Antichain, AntichainRef};
43
44use crate::column_pager::{self, ColumnPager, PagedColumn};
45use crate::columnar::Column;
46use crate::columnar::batcher::{empty_chunk, recycle_chunk};
47
48/// Max recycled empty chunks held in the per-batcher stash. Deliberately
49/// tight: the stash is a hot-buffer cache for the result/keep/ship churn,
50/// not a hoard. Stash entries are cleared `Column::Typed` allocations that
51/// retain capacity but are *not* tracked by [`ColumnPager`]'s
52/// `ResidentTicket` accounting, so each one is a chunk's worth of resident
53/// bytes the pager's budget doesn't see. There's one stash per arrange
54/// batcher per worker, so this multiplies fast.
55///
56/// 2 covers steady-state reuse for both code paths: `merge_chains` ships
57/// `result` and immediately pulls a refill; `extract_chain` ships `keep` /
58/// `ship` and pulls a refill for whichever was at capacity. Heads that
59/// drain mid-loop arrive resident from `FetchIter`, so the whole-chunk
60/// passthrough fast path keeps most of them off the merge inner loop
61/// entirely — only a small minority ever flow back through the stash.
62const STASH_CAP: usize = 2;
63
64/// Don't park a buffer larger than this in the free-list. A transiently
65/// oversize merge buffer (post-explosion, past the natural ship threshold)
66/// held resident would compete with the pager's budget; drop it and let a
67/// fresh default regrow. 2 × the natural ship word count (≈ 4 MiB
68/// serialized) keeps normal ship-sized chunks while excluding pathological
69/// ones.
70const MAX_RECYCLE_BYTES: usize = 1 << 22;
71
72/// Recycle `chunk` only if the stash isn't already at [`STASH_CAP`] and the
73/// chunk isn't oversize per [`MAX_RECYCLE_BYTES`]. `length_in_bytes` is
74/// measured before clear, so it reflects the data the chunk was carrying
75/// (a proxy for the capacity we'd park).
76fn recycle_capped<C: Columnar>(chunk: Column<C>, stash: &mut Vec<Column<C>>) {
77    if stash.len() < STASH_CAP && chunk.length_in_bytes() <= MAX_RECYCLE_BYTES {
78        recycle_chunk(chunk, stash);
79    }
80}
81
82/// Drives the merge-batcher over [`Column`] chunks routed through a
83/// [`ColumnPager`].
84///
85/// Chains hold [`PagedColumn`] entries rather than resident [`Column`]s, so
86/// each insert / merge / extract step can hand its output to the pager and
87/// store whatever the policy returns (resident, paged, or compressed). Reads
88/// during merge materialize lazily via [`FetchIter`].
89///
90/// Resolves its pager lazily per call via [`column_pager::global_pager`], so
91/// late-arriving dyncfg updates (e.g. `enable_column_paged_batcher` flipping
92/// on after the batcher was constructed) take effect without rebuilding the
93/// operator. Tests may override that lookup via [`Self::set_pager`].
94pub struct ColumnMergeBatcher<D, T, R>
95where
96    D: Columnar,
97    T: Columnar,
98    R: Columnar,
99{
100    chains: Vec<VecDeque<PagedColumn<(D, T, R)>>>,
101    lower: Antichain<T>,
102    frontier: Antichain<T>,
103    /// Recycled empty `Column::Typed` chunks. Drained heads and shipped result
104    /// buffers feed in here; subsequent merge / extract calls pop from here
105    /// instead of starting from a zero-capacity `Column::default()`. Mirrors
106    /// the stash carried by the upstream `differential_dataflow` merge-batcher
107    /// framework, which this type forks. Without it, each shipped chunk
108    /// triggers a fresh per-leaf grow cycle and per-merge-round allocation
109    /// dominates the inner loop.
110    stash: Vec<Column<(D, T, R)>>,
111    /// Optional override. `None` means "read [`column_pager::global_pager`]
112    /// fresh on every use" — the production path, so worker_config dyncfg
113    /// changes that re-install the process-global pager take effect on the
114    /// very next chunk this batcher processes.
115    pager_override: Option<ColumnPager>,
116    logger: Option<Logger>,
117    operator_id: usize,
118}
119
120impl<D, T, R> ColumnMergeBatcher<D, T, R>
121where
122    D: Columnar,
123    T: Columnar,
124    R: Columnar,
125{
126    /// Pin the pager this batcher uses, overriding the thread-local lookup.
127    /// Mainly for tests; production should leave the override unset so
128    /// dyncfg-driven re-installs take effect immediately.
129    pub fn set_pager(&mut self, pager: ColumnPager) {
130        self.pager_override = Some(pager);
131    }
132
133    /// Current pager — override if set, else the process-global pager
134    /// installed by `apply_worker_config`. `ColumnPager` is cheaply
135    /// cloneable (Arc inside).
136    fn pager(&self) -> ColumnPager {
137        self.pager_override
138            .clone()
139            .unwrap_or_else(column_pager::global_pager)
140    }
141
142    /// Push a chain into `self.chains`, emitting a positive `BatcherEvent`
143    /// covering its resident entries.
144    fn chain_push(&mut self, chain: VecDeque<PagedColumn<(D, T, R)>>) {
145        self.emit_account(&chain, 1);
146        self.chains.push(chain);
147    }
148
149    /// Pop a chain from `self.chains`, emitting a negative `BatcherEvent`
150    /// retracting its resident entries.
151    ///
152    /// Invariant for the retract to reconcile against the matching
153    /// `chain_push`: chain entries are never mutated in place between push
154    /// and pop. The only allowed mutation is a full pop / push pair (see
155    /// `insert_chain` and `merge_by`), so each entry's accounting category
156    /// — `Resident` vs `Paged` vs `Compressed` — is the same at both ends.
157    /// If a future change ever pages an entry out in place after push, this
158    /// path silently double-counts.
159    fn chain_pop(&mut self) -> Option<VecDeque<PagedColumn<(D, T, R)>>> {
160        let chain = self.chains.pop()?;
161        self.emit_account(&chain, -1);
162        Some(chain)
163    }
164
165    /// Emit a single `BatcherEvent` summing resident accounting across
166    /// `chain` with the given sign. No-op when no logger is attached.
167    fn emit_account(&self, chain: &VecDeque<PagedColumn<(D, T, R)>>, diff: isize) {
168        let Some(logger) = &self.logger else {
169            return;
170        };
171        let (mut records, mut size, mut capacity, mut allocations) =
172            (0isize, 0isize, 0isize, 0isize);
173        for entry in chain {
174            let (r, s, c, a) = account_chunk(entry);
175            records = records.saturating_add_unsigned(r);
176            size = size.saturating_add_unsigned(s);
177            capacity = capacity.saturating_add_unsigned(c);
178            allocations = allocations.saturating_add_unsigned(a);
179        }
180        logger.log(BatcherEvent {
181            operator: self.operator_id,
182            records_diff: records.saturating_mul(diff),
183            size_diff: size.saturating_mul(diff),
184            capacity_diff: capacity.saturating_mul(diff),
185            allocations_diff: allocations.saturating_mul(diff),
186        });
187    }
188}
189
190impl<D, T, R> Drop for ColumnMergeBatcher<D, T, R>
191where
192    D: Columnar,
193    T: Columnar,
194    R: Columnar,
195{
196    fn drop(&mut self) {
197        // Retract accounting for any chains still resident at drop time so
198        // the BatcherEvent counters end at zero per-operator.
199        while self.chain_pop().is_some() {}
200    }
201}
202
203/// Resident-only accounting. Returns `(records, size_bytes, capacity_bytes,
204/// allocations)` for a single chain entry; paged-out entries contribute 0
205/// across the board.
206///
207/// `BatcherEvent` feeds the `mz_arrangement_batcher_*_raw` introspection
208/// tables, which downstream surface as memory-resource dashboards. Bytes
209/// living on swap or in a pager file aren't part of RSS and shouldn't be
210/// reported there.
211fn account_chunk<C: Columnar>(entry: &PagedColumn<C>) -> (usize, usize, usize, usize) {
212    match entry {
213        PagedColumn::Resident(col, _) => {
214            let records = usize::try_from(col.record_count()).expect("non-negative");
215            let bytes = col.length_in_bytes();
216            (records, bytes, bytes, 1)
217        }
218        PagedColumn::Paged { .. } | PagedColumn::Compressed { .. } => (0, 0, 0, 0),
219    }
220}
221
222impl<D, T, R> Batcher for ColumnMergeBatcher<D, T, R>
223where
224    D: Columnar,
225    for<'a> columnar::Ref<'a, D>: Copy + Ord,
226    T: Columnar + Default + Timestamp + PartialOrder,
227    for<'a> columnar::Ref<'a, T>: Copy + Ord,
228    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
229    for<'a> columnar::Ref<'a, R>: Ord,
230{
231    type Output = Column<(D, T, R)>;
232    type Time = T;
233
234    fn new(logger: Option<Logger>, operator_id: usize) -> Self {
235        Self {
236            chains: Vec::new(),
237            lower: Antichain::from_elem(T::minimum()),
238            frontier: Antichain::new(),
239            stash: Vec::new(),
240            pager_override: None,
241            logger,
242            operator_id,
243        }
244    }
245
246    fn seal(
247        &mut self,
248        upper: Antichain<Self::Time>,
249    ) -> (Vec<Self::Output>, Description<Self::Time>) {
250        let pager = self.pager();
251        // Merge all remaining chains into one.
252        while self.chains.len() > 1 {
253            let a = self.chain_pop().unwrap();
254            let b = self.chain_pop().unwrap();
255            let merged = self.merge_by(a, b);
256            self.chain_push(merged);
257        }
258        let merged = self.chain_pop().unwrap_or_default();
259
260        // Extract `merged` into `readied` (ship side, materialized for the
261        // builder) and `kept_chain` (keep side, stays paged for the next
262        // round).
263        let mut readied: Vec<Column<(D, T, R)>> = Vec::new();
264        let mut kept_chain: VecDeque<PagedColumn<(D, T, R)>> = VecDeque::new();
265        self.frontier.clear();
266        {
267            let pager = &pager;
268            let frontier = &mut self.frontier;
269            let stash = &mut self.stash;
270            extract_chain(
271                FetchIter::new(merged, pager),
272                upper.borrow(),
273                frontier,
274                |paged| readied.push(pager.take(paged)),
275                |paged| kept_chain.push_back(paged),
276                stash,
277            );
278        }
279
280        if !kept_chain.is_empty() {
281            self.chain_push(kept_chain);
282        }
283
284        let description = Description::new(
285            self.lower.clone(),
286            upper.clone(),
287            Antichain::from_elem(T::minimum()),
288        );
289        self.lower = upper;
290
291        // Drop the recycle stash now that this round's hot work is done:
292        // the next merge re-pays one chunk's worth of leaf grow tax, and in
293        // exchange the leaf bytes are not held resident across what may be
294        // a quiet stretch.
295        self.stash.clear();
296
297        (readied, description)
298    }
299
300    fn frontier(&mut self) -> AntichainRef<'_, Self::Time> {
301        self.frontier.borrow()
302    }
303}
304
305impl<D, T, R> PushInto<Column<(D, T, R)>> for ColumnMergeBatcher<D, T, R>
306where
307    D: Columnar,
308    for<'a> columnar::Ref<'a, D>: Copy + Ord,
309    T: Columnar + Default + Clone + PartialOrder,
310    for<'a> columnar::Ref<'a, T>: Copy + Ord,
311    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
312{
313    /// Accept an already-consolidated chunk from the upstream chunker, route
314    /// it through the pager, and insert it as a singleton chain.
315    fn push_into(&mut self, mut chunk: Column<(D, T, R)>) {
316        let pager = self.pager();
317        let paged = pager.page(&mut chunk);
318        self.insert_chain(VecDeque::from([paged]));
319    }
320}
321
322impl<D, T, R> ColumnMergeBatcher<D, T, R>
323where
324    D: Columnar,
325    for<'a> columnar::Ref<'a, D>: Copy + Ord,
326    T: Columnar + Default + Clone + PartialOrder,
327    for<'a> columnar::Ref<'a, T>: Copy + Ord,
328    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
329{
330    /// Insert `chain` and rebalance: while the youngest chain is at least
331    /// half the size of its predecessor, merge them.
332    fn insert_chain(&mut self, chain: VecDeque<PagedColumn<(D, T, R)>>) {
333        if chain.is_empty() {
334            return;
335        }
336        self.chain_push(chain);
337        while self.chains.len() > 1
338            && self.chains[self.chains.len() - 1].len()
339                >= self.chains[self.chains.len() - 2].len() / 2
340        {
341            let a = self.chain_pop().unwrap();
342            let b = self.chain_pop().unwrap();
343            let merged = self.merge_by(a, b);
344            self.chain_push(merged);
345        }
346    }
347
348    /// Merge two sorted chains. Outputs are routed through `self.pager.page`
349    /// per chunk produced, so the result chain holds `PagedColumn`s and the
350    /// caller never sees a fully materialized merge result.
351    fn merge_by(
352        &mut self,
353        a: VecDeque<PagedColumn<(D, T, R)>>,
354        b: VecDeque<PagedColumn<(D, T, R)>>,
355    ) -> VecDeque<PagedColumn<(D, T, R)>> {
356        let mut output: VecDeque<PagedColumn<(D, T, R)>> = VecDeque::new();
357        let pager = self.pager();
358        let pager = &pager;
359        let stash = &mut self.stash;
360        merge_chains(
361            FetchIter::new(a, pager),
362            FetchIter::new(b, pager),
363            |paged| output.push_back(paged),
364            stash,
365        );
366        output
367    }
368}
369
370/// Streaming materializer over a chain of [`PagedColumn`] entries.
371///
372/// `next` consumes one entry and calls [`ColumnPager::take`] to produce a
373/// resident [`Column`]. Bounds materialized chunks to whatever the consumer
374/// holds (typically one head per chain in [`merge_chains`]).
375pub struct FetchIter<'a, D, T, R>
376where
377    (D, T, R): Columnar,
378{
379    queue: VecDeque<PagedColumn<(D, T, R)>>,
380    pager: &'a ColumnPager,
381}
382
383impl<'a, D, T, R> FetchIter<'a, D, T, R>
384where
385    (D, T, R): Columnar,
386{
387    /// Wraps `queue` for streaming materialization through `pager`.
388    pub fn new(queue: VecDeque<PagedColumn<(D, T, R)>>, pager: &'a ColumnPager) -> Self {
389        Self { queue, pager }
390    }
391
392    /// Borrow the pager backing this iter so drivers can route output chunks
393    /// back through `page()` without threading a separate `&pager`. The
394    /// returned reference is tied to the outer `'a`, not to `&self`, so it
395    /// stays valid across subsequent `next()` calls.
396    pub fn pager(&self) -> &'a ColumnPager {
397        self.pager
398    }
399
400    /// Drain remaining queued entries as `PagedColumn`s without materializing.
401    /// Used by `merge_chains`'s drain-tail phase: once the other side is
402    /// exhausted, the remaining entries on this side can pass straight to the
403    /// output sink.
404    pub fn into_paged(self) -> std::collections::vec_deque::IntoIter<PagedColumn<(D, T, R)>> {
405        self.queue.into_iter()
406    }
407}
408
409impl<D, T, R> Iterator for FetchIter<'_, D, T, R>
410where
411    (D, T, R): Columnar,
412{
413    type Item = Column<(D, T, R)>;
414
415    fn next(&mut self) -> Option<Self::Item> {
416        self.queue.pop_front().map(|p| self.pager.take(p))
417    }
418}
419
420/// Two-way merge driver. Reuses today's per-chunk gallop / ship-threshold
421/// logic from `Column::merge_from`, but pulls heads from [`FetchIter`] and
422/// emits finished output chunks through `sink` after routing them through
423/// the pager exposed by [`FetchIter::pager`].
424///
425/// `stash` is a pool of empty `Column::Typed` chunks. Drained heads and
426/// shipped result buffers get recycled into it; the next result chunk is
427/// pulled from it instead of starting from a zero-capacity default. This
428/// matches the recycling discipline the upstream `differential_dataflow`
429/// merge-batcher carries via `Merger::merge`'s `stash` parameter.
430///
431/// Whole-chunk passthrough mirrors the fast path in `super::batcher`'s
432/// `Merger::merge`: a head that sorts entirely before the other side's
433/// current record ships wholesale.
434pub fn merge_chains<D, T, R, Sink>(
435    list1: FetchIter<'_, D, T, R>,
436    list2: FetchIter<'_, D, T, R>,
437    mut sink: Sink,
438    stash: &mut Vec<Column<(D, T, R)>>,
439) where
440    D: Columnar,
441    for<'a> columnar::Ref<'a, D>: Copy + Ord,
442    T: Columnar + Default + Clone + PartialOrder,
443    for<'a> columnar::Ref<'a, T>: Copy + Ord,
444    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
445    Sink: FnMut(PagedColumn<(D, T, R)>),
446{
447    let pager = list1.pager();
448    let mut list1 = list1;
449    let mut list2 = list2;
450
451    let mut heads = [
452        list1.next().unwrap_or_default(),
453        list2.next().unwrap_or_default(),
454    ];
455    let mut positions = [0usize, 0usize];
456    let mut result: Column<(D, T, R)> = empty_chunk(stash);
457
458    loop {
459        let upper_l = heads[0].borrow().len();
460        let upper_r = heads[1].borrow().len();
461        if positions[0] >= upper_l || positions[1] >= upper_r {
462            break;
463        }
464
465        // Whole-chunk passthrough. Two probes on already-resident heads.
466        let lhs_passthrough = positions[0] == 0 && upper_l > 0 && {
467            let lhs = heads[0].borrow();
468            let rhs = heads[1].borrow();
469            let last_l = (lhs.0.get(upper_l - 1), lhs.1.get(upper_l - 1));
470            let cur_r = (rhs.0.get(positions[1]), rhs.1.get(positions[1]));
471            last_l < cur_r
472        };
473        if lhs_passthrough {
474            if !result.is_empty() {
475                sink(pager.page(&mut result));
476                if let Some(reuse) = stash.pop() {
477                    result = reuse;
478                }
479            }
480            let mut head = std::mem::replace(&mut heads[0], list1.next().unwrap_or_default());
481            sink(pager.page(&mut head));
482            positions[0] = 0;
483            continue;
484        }
485
486        let rhs_passthrough = positions[1] == 0 && upper_r > 0 && {
487            let lhs = heads[0].borrow();
488            let rhs = heads[1].borrow();
489            let last_r = (rhs.0.get(upper_r - 1), rhs.1.get(upper_r - 1));
490            let cur_l = (lhs.0.get(positions[0]), lhs.1.get(positions[0]));
491            last_r < cur_l
492        };
493        if rhs_passthrough {
494            if !result.is_empty() {
495                sink(pager.page(&mut result));
496                if let Some(reuse) = stash.pop() {
497                    result = reuse;
498                }
499            }
500            let mut head = std::mem::replace(&mut heads[1], list2.next().unwrap_or_default());
501            sink(pager.page(&mut head));
502            positions[1] = 0;
503            continue;
504        }
505
506        let yielded = result.merge_from(&mut heads, &mut positions);
507
508        if positions[0] >= heads[0].borrow().len() {
509            let old = std::mem::replace(&mut heads[0], list1.next().unwrap_or_default());
510            recycle_capped(old, stash);
511            positions[0] = 0;
512        }
513        if positions[1] >= heads[1].borrow().len() {
514            let old = std::mem::replace(&mut heads[1], list2.next().unwrap_or_default());
515            recycle_capped(old, stash);
516            positions[1] = 0;
517        }
518        if yielded || result.at_capacity() {
519            sink(pager.page(&mut result));
520            // `pager.page` either took `result`'s allocation (Skip path leaves
521            // a zero-cap default) or kept the Typed buffer (Paged / Compressed
522            // paths clear in place). Pull a fresh chunk from the stash so the
523            // next `merge_from` starts with retained capacity; if the stash is
524            // empty, fall back to whatever `result` already is.
525            if let Some(reuse) = stash.pop() {
526                result = reuse;
527            }
528        }
529    }
530
531    // Drain remaining: copy partial head through `merge_from`'s 1-input
532    // dispatch, then hand the rest of the chain's `PagedColumn`s straight to
533    // the sink without materializing.
534    drain_side(
535        &mut heads[0],
536        &mut positions[0],
537        list1,
538        &mut result,
539        &mut sink,
540        pager,
541        stash,
542    );
543    drain_side(
544        &mut heads[1],
545        &mut positions[1],
546        list2,
547        &mut result,
548        &mut sink,
549        pager,
550        stash,
551    );
552
553    if !result.is_empty() {
554        sink(pager.page(&mut result));
555    } else {
556        // Empty `result` may still carry a useful Typed allocation; recycle
557        // so subsequent calls (next `merge_by`, the seal `extract_chain`)
558        // can pick it up.
559        recycle_capped(result, stash);
560    }
561    // Recycle the now-exhausted (or default) head slots too — for `Resident`
562    // heads that finished naturally, this preserves their Typed allocation
563    // for the next call.
564    let [h0, h1] = heads;
565    recycle_capped(h0, stash);
566    recycle_capped(h1, stash);
567}
568
569/// Helper for `merge_chains`'s drain phase: copy a partially-consumed head
570/// into `result` (via 1-input `merge_from`), ship `result` if non-empty, then
571/// pass the remaining queued `PagedColumn`s straight through.
572fn drain_side<D, T, R, Sink>(
573    head: &mut Column<(D, T, R)>,
574    pos: &mut usize,
575    rest: FetchIter<'_, D, T, R>,
576    result: &mut Column<(D, T, R)>,
577    sink: &mut Sink,
578    pager: &ColumnPager,
579    stash: &mut Vec<Column<(D, T, R)>>,
580) where
581    D: Columnar,
582    for<'a> columnar::Ref<'a, D>: Copy + Ord,
583    T: Columnar + Default + Clone + PartialOrder,
584    for<'a> columnar::Ref<'a, T>: Copy + Ord,
585    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
586    Sink: FnMut(PagedColumn<(D, T, R)>),
587{
588    if *pos < head.borrow().len() {
589        // 1-input dispatch — bulk copy that runs to completion.
590        let _ = result.merge_from(std::slice::from_mut(head), std::slice::from_mut(pos));
591    }
592    if !result.is_empty() {
593        sink(pager.page(result));
594        if let Some(reuse) = stash.pop() {
595            *result = reuse;
596        }
597    }
598    for paged in rest.into_paged() {
599        sink(paged);
600    }
601}
602
603/// Streaming extract: walks `merged` chunk-by-chunk via `Column::extract`,
604/// routing each filled keep/ship chunk through its sink after pageing.
605/// Mirrors the per-chunk ship-threshold yield already inside
606/// `Column::extract`.
607///
608/// `stash` carries recycled `Column::Typed` buffers in and out so the
609/// per-chunk extract loop doesn't restart from zero capacity each time
610/// `keep_buf` / `ship_buf` ships and the source `buffer` is dropped.
611pub fn extract_chain<D, T, R, SinkShip, SinkKeep>(
612    merged: FetchIter<'_, D, T, R>,
613    upper: AntichainRef<T>,
614    frontier: &mut Antichain<T>,
615    mut ship: SinkShip,
616    mut keep: SinkKeep,
617    stash: &mut Vec<Column<(D, T, R)>>,
618) where
619    D: Columnar,
620    for<'a> columnar::Ref<'a, D>: Copy + Ord,
621    T: Columnar + Default + Clone + PartialOrder,
622    for<'a> columnar::Ref<'a, T>: Copy + Ord,
623    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
624    SinkShip: FnMut(PagedColumn<(D, T, R)>),
625    SinkKeep: FnMut(PagedColumn<(D, T, R)>),
626{
627    let pager = merged.pager();
628    let mut keep_buf: Column<(D, T, R)> = empty_chunk(stash);
629    let mut ship_buf: Column<(D, T, R)> = empty_chunk(stash);
630
631    for mut buffer in merged {
632        let mut position = 0;
633        let len = buffer.borrow().len();
634        while position < len {
635            buffer.extract(&mut position, upper, frontier, &mut keep_buf, &mut ship_buf);
636            if keep_buf.at_capacity() {
637                keep(pager.page(&mut keep_buf));
638                if let Some(reuse) = stash.pop() {
639                    keep_buf = reuse;
640                }
641            }
642            if ship_buf.at_capacity() {
643                ship(pager.page(&mut ship_buf));
644                if let Some(reuse) = stash.pop() {
645                    ship_buf = reuse;
646                }
647            }
648        }
649        // Buffer fully consumed; recycle whatever Typed allocation it had.
650        recycle_capped(buffer, stash);
651    }
652    if !keep_buf.is_empty() {
653        keep(pager.page(&mut keep_buf));
654    } else {
655        recycle_capped(keep_buf, stash);
656    }
657    if !ship_buf.is_empty() {
658        ship(pager.page(&mut ship_buf));
659    } else {
660        recycle_capped(ship_buf, stash);
661    }
662}
663
664#[cfg(test)]
665#[allow(clippy::clone_on_ref_ptr)]
666mod tests {
667    use std::sync::Arc;
668
669    use columnar::Index;
670
671    use super::*;
672    use crate::column_pager::{PageDecision, PageEvent, PageHint, PagingPolicy};
673
674    type KvUpdate = ((u64, u64), u64, i64);
675
676    fn col(rows: &[KvUpdate]) -> Column<KvUpdate> {
677        let mut c: Column<KvUpdate> = Default::default();
678        for &t in rows {
679            c.push_into(t);
680        }
681        c
682    }
683
684    fn collect_pc(chunks: &[PagedColumn<KvUpdate>], pager: &ColumnPager) -> Vec<KvUpdate> {
685        // `collect_pc` peeks via materialization on a side path so the test's
686        // assertions don't consume the chain.
687        chunks
688            .iter()
689            .flat_map(|p| {
690                let view: Column<KvUpdate> = match p {
691                    PagedColumn::Resident(c, _) => clone_column(c),
692                    _ => pager.take(clone_paged(p)),
693                };
694                collect_column(&view).into_iter()
695            })
696            .collect()
697    }
698
699    fn collect_column(c: &Column<KvUpdate>) -> Vec<KvUpdate> {
700        c.borrow()
701            .into_index_iter()
702            .map(|((k, v), t, r)| {
703                (
704                    (u64::into_owned(k), u64::into_owned(v)),
705                    u64::into_owned(t),
706                    i64::into_owned(r),
707                )
708            })
709            .collect()
710    }
711
712    fn clone_column(c: &Column<KvUpdate>) -> Column<KvUpdate> {
713        // `Column` is `Clone` when `C::Container: Clone`, which is true for
714        // tuple-of-primitive containers. Used so test helpers can peek at a
715        // chain without consuming it.
716        c.clone()
717    }
718
719    /// Helper that bypasses `pager.take` for non-`Resident` variants by
720    /// taking and re-pageing. Only used in test inspection paths where the
721    /// extra round-trip is acceptable.
722    fn clone_paged(p: &PagedColumn<KvUpdate>) -> PagedColumn<KvUpdate> {
723        match p {
724            PagedColumn::Resident(c, _) => {
725                // Wrap via a disabled pager so the ticket is fresh.
726                let mut c = c.clone();
727                ColumnPager::disabled().page(&mut c)
728            }
729            // For paged/compressed variants we can't clone without
730            // re-reading; the tests below only inspect Resident chains.
731            _ => panic!("clone_paged only supports Resident"),
732        }
733    }
734
735    /// Always-page policy: bypasses any resident shortcut so we can assert
736    /// the chains remain in `Paged` form regardless of memory pressure.
737    struct ForcePagePolicy {
738        out: std::sync::atomic::AtomicUsize,
739        r#in: std::sync::atomic::AtomicUsize,
740    }
741    impl ForcePagePolicy {
742        fn new() -> Arc<Self> {
743            Arc::new(Self {
744                out: std::sync::atomic::AtomicUsize::new(0),
745                r#in: std::sync::atomic::AtomicUsize::new(0),
746            })
747        }
748    }
749    impl PagingPolicy for ForcePagePolicy {
750        fn decide(&self, _hint: PageHint) -> PageDecision {
751            PageDecision::Page {
752                backend: mz_ore::pager::Backend::Swap,
753                codec: None,
754            }
755        }
756        fn record(&self, event: PageEvent) {
757            use std::sync::atomic::Ordering;
758            match event {
759                PageEvent::PagedOut { .. } => {
760                    self.out.fetch_add(1, Ordering::Relaxed);
761                }
762                PageEvent::PagedIn { .. } => {
763                    self.r#in.fetch_add(1, Ordering::Relaxed);
764                }
765                _ => {}
766            }
767        }
768    }
769
770    /// Wrap a Vec<Column> as a paged chain for `FetchIter`.
771    fn to_chain(
772        cols: Vec<Column<KvUpdate>>,
773        pager: &ColumnPager,
774    ) -> VecDeque<PagedColumn<KvUpdate>> {
775        cols.into_iter().map(|mut c| pager.page(&mut c)).collect()
776    }
777
778    /// Drive `merge_chains` with a disabled pager and return owned tuples.
779    fn drive_merge(chain1: Vec<Column<KvUpdate>>, chain2: Vec<Column<KvUpdate>>) -> Vec<KvUpdate> {
780        let pager = ColumnPager::disabled();
781        let q1 = to_chain(chain1, &pager);
782        let q2 = to_chain(chain2, &pager);
783        let mut output: Vec<PagedColumn<KvUpdate>> = Vec::new();
784        let mut stash: Vec<Column<KvUpdate>> = Vec::new();
785        merge_chains(
786            FetchIter::new(q1, &pager),
787            FetchIter::new(q2, &pager),
788            |paged| output.push(paged),
789            &mut stash,
790        );
791        collect_pc(&output, &pager)
792    }
793
794    /// Disjoint chains: same data as the legacy passthrough test. Without
795    /// passthrough, the merger runs per-record but should still produce the
796    /// fully ordered output.
797    #[mz_ore::test]
798    fn merge_chains_disjoint_ranges() {
799        let out = drive_merge(
800            vec![
801                col(&[((0, 0), 0, 1), ((1, 0), 0, 1)]),
802                col(&[((2, 0), 0, 1), ((3, 0), 0, 1)]),
803            ],
804            vec![
805                col(&[((10, 0), 0, 1), ((11, 0), 0, 1)]),
806                col(&[((12, 0), 0, 1), ((13, 0), 0, 1)]),
807            ],
808        );
809        let expected: Vec<_> = (0..4u64)
810            .map(|d| ((d, 0u64), 0u64, 1i64))
811            .chain((10..14u64).map(|d| ((d, 0u64), 0u64, 1i64)))
812            .collect();
813        assert_eq!(out, expected);
814    }
815
816    #[mz_ore::test]
817    fn merge_chains_interleaved() {
818        let out = drive_merge(
819            vec![
820                col(&[((0, 0), 0, 1), ((2, 0), 0, 1)]),
821                col(&[((4, 0), 0, 1), ((6, 0), 0, 1)]),
822            ],
823            vec![
824                col(&[((1, 0), 0, 1), ((3, 0), 0, 1)]),
825                col(&[((5, 0), 0, 1), ((7, 0), 0, 1)]),
826            ],
827        );
828        let expected: Vec<_> = (0..8u64).map(|d| ((d, 0u64), 0u64, 1i64)).collect();
829        assert_eq!(out, expected);
830    }
831
832    /// Equal-key consolidation across chunk boundaries: chain1's last record
833    /// shares `(d, t)` with chain2's first; sum of diffs should land on a
834    /// single output record.
835    #[mz_ore::test]
836    fn merge_chains_equal_boundary() {
837        let out = drive_merge(
838            vec![col(&[((0, 0), 0, 1), ((5, 0), 0, 1)])],
839            vec![col(&[((5, 0), 0, 1), ((10, 0), 0, 1)])],
840        );
841        assert_eq!(out, vec![((0, 0), 0, 1), ((5, 0), 0, 2), ((10, 0), 0, 1)]);
842    }
843
844    /// Regression: under the disabled (always-resident) pager, shipped chunks
845    /// must be serialized into a fitting `Column::Align`, never parked as
846    /// `Column::Typed`. A `Typed` result carries `Column::merge_from`'s
847    /// worst-case `reserve_for` capacity; leaving it in the chain across merge
848    /// rounds was the dominant source of merge-batcher resident memory. Only
849    /// the live accumulator (`result`) and not-yet-shipped heads may be
850    /// `Typed` — every entry that reaches the sink should be `Align`.
851    #[mz_ore::test]
852    fn merge_chains_ships_fitting_align() {
853        let pager = ColumnPager::disabled();
854        // Interleaved keys force the per-record merge path: records flow
855        // through the `result` accumulator and ship as a merged chunk rather
856        // than passing a head through wholesale.
857        let q1 = to_chain(vec![col(&[((0, 0), 0, 1), ((2, 0), 0, 1)])], &pager);
858        let q2 = to_chain(vec![col(&[((1, 0), 0, 1), ((3, 0), 0, 1)])], &pager);
859
860        let mut output: Vec<PagedColumn<KvUpdate>> = Vec::new();
861        let mut stash: Vec<Column<KvUpdate>> = Vec::new();
862        merge_chains(
863            FetchIter::new(q1, &pager),
864            FetchIter::new(q2, &pager),
865            |paged| output.push(paged),
866            &mut stash,
867        );
868
869        assert!(!output.is_empty(), "merge produced no chunks");
870        for entry in &output {
871            match entry {
872                PagedColumn::Resident(col, _) => assert!(
873                    matches!(col, Column::Align(_)),
874                    "shipped chunk parked as non-Align resident: {:?}",
875                    std::mem::discriminant(col),
876                ),
877                other => panic!(
878                    "disabled pager should ship Resident, got a paged variant: {:?}",
879                    std::mem::discriminant(other)
880                ),
881            }
882        }
883
884        // Sanity: data round-trips through the fitting Align buffers.
885        assert_eq!(
886            collect_pc(&output, &pager),
887            vec![
888                ((0, 0), 0, 1),
889                ((1, 0), 0, 1),
890                ((2, 0), 0, 1),
891                ((3, 0), 0, 1)
892            ],
893        );
894    }
895
896    /// Same merge, force-paged: chains stay in `Paged` form throughout, and
897    /// the consolidated result still matches.
898    #[mz_ore::test]
899    fn merge_chains_force_paged_round_trip() {
900        let policy = ForcePagePolicy::new();
901        let pager = ColumnPager::new(policy.clone());
902        let q1 = to_chain(vec![col(&[((0, 0), 0, 1), ((2, 0), 0, 1)])], &pager);
903        let q2 = to_chain(vec![col(&[((1, 0), 0, 1), ((3, 0), 0, 1)])], &pager);
904
905        // Confirm the chains started paged-out (not Resident).
906        assert!(matches!(q1.front().unwrap(), PagedColumn::Paged { .. }));
907        assert!(matches!(q2.front().unwrap(), PagedColumn::Paged { .. }));
908
909        let mut output: Vec<PagedColumn<KvUpdate>> = Vec::new();
910        let mut stash: Vec<Column<KvUpdate>> = Vec::new();
911        merge_chains(
912            FetchIter::new(q1, &pager),
913            FetchIter::new(q2, &pager),
914            |paged| output.push(paged),
915            &mut stash,
916        );
917
918        // Output entries should also have been routed through the pager.
919        for p in &output {
920            assert!(matches!(p, PagedColumn::Paged { .. }));
921        }
922
923        // Materialize the output and check correctness.
924        let mut collected = Vec::new();
925        for p in output {
926            let c = pager.take(p);
927            collected.extend(collect_column(&c));
928        }
929        let expected: Vec<_> = (0..4u64).map(|d| ((d, 0u64), 0u64, 1i64)).collect();
930        assert_eq!(collected, expected);
931    }
932
933    #[mz_ore::test]
934    fn extract_chain_partitions_by_frontier() {
935        let pager = ColumnPager::disabled();
936        let data = vec![
937            ((0, 0), 0u64, 1i64),
938            ((1, 0), 1, 1),
939            ((2, 0), 2, 1),
940            ((3, 0), 3, 1),
941        ];
942        let chain = to_chain(vec![col(&data)], &pager);
943        let upper = Antichain::from_elem(2u64);
944        let mut frontier: Antichain<u64> = Antichain::new();
945        let mut ship: Vec<PagedColumn<KvUpdate>> = Vec::new();
946        let mut keep: Vec<PagedColumn<KvUpdate>> = Vec::new();
947        let mut stash: Vec<Column<KvUpdate>> = Vec::new();
948
949        extract_chain(
950            FetchIter::new(chain, &pager),
951            upper.borrow(),
952            &mut frontier,
953            |p| ship.push(p),
954            |p| keep.push(p),
955            &mut stash,
956        );
957
958        let shipped = collect_pc(&ship, &pager);
959        let kept = collect_pc(&keep, &pager);
960        for (_, t, _) in &shipped {
961            assert!(*t < 2, "shipped time {t} should be < upper");
962        }
963        for (_, t, _) in &kept {
964            assert!(*t >= 2, "kept time {t} should be >= upper");
965        }
966        assert_eq!(shipped.len() + kept.len(), data.len());
967    }
968
969    #[mz_ore::test]
970    fn batcher_seal_round_trip() {
971        let mut b: ColumnMergeBatcher<(u64, u64), u64, i64> =
972            differential_dataflow::trace::Batcher::new(None, 0);
973        // Two pushes; second has an equal-key collision with the first.
974        // Inputs arrive pre-consolidated chunk-by-chunk, as from the upstream
975        // chunker.
976        let input1 = col(&[((1, 1), 0, 1), ((2, 0), 0, 1), ((3, 0), 0, 1)]);
977        let input2 = col(&[((2, 0), 0, 2), ((4, 0), 0, 1)]);
978        b.push_into(input1);
979        b.push_into(input2);
980
981        // Seal everything (upper = ∞-ish, here just past any time we used).
982        let upper = Antichain::from_elem(u64::MAX);
983        let (chain, _description) = differential_dataflow::trace::Batcher::seal(&mut b, upper);
984        let out: Vec<KvUpdate> = chain.iter().flat_map(collect_column).collect();
985
986        // (2, 0)@0 was pushed with +1 then +2; sums to +3 after consolidation.
987        let mut expected = vec![
988            ((1u64, 1u64), 0u64, 1i64),
989            ((2, 0), 0, 3),
990            ((3, 0), 0, 1),
991            ((4, 0), 0, 1),
992        ];
993        expected.sort();
994        let mut out_sorted = out.clone();
995        out_sorted.sort();
996        assert_eq!(out_sorted, expected);
997    }
998
999    #[mz_ore::test]
1000    fn account_chunk_resident_vs_paged() {
1001        let policy = ForcePagePolicy::new();
1002        let pager_paged = ColumnPager::new(policy.clone());
1003        let pager_res = ColumnPager::disabled();
1004
1005        let mut c1 = col(&[((1, 1), 0, 1), ((2, 0), 0, 1), ((3, 0), 0, 1)]);
1006        let resident = pager_res.page(&mut c1);
1007        let (records, size, capacity, allocations) = account_chunk(&resident);
1008        assert_eq!(records, 3);
1009        assert!(size > 0);
1010        assert_eq!(size, capacity);
1011        assert_eq!(allocations, 1);
1012
1013        let mut c2 = col(&[((1, 1), 0, 1), ((2, 0), 0, 1)]);
1014        let paged = pager_paged.page(&mut c2);
1015        assert!(matches!(paged, PagedColumn::Paged { .. }));
1016        // Paged variants contribute zero to memory accounting.
1017        assert_eq!(account_chunk(&paged), (0, 0, 0, 0));
1018    }
1019
1020    #[mz_ore::test]
1021    fn batcher_seal_keeps_kept_chain_paged() {
1022        // Force-page policy; verify that after seal, the kept chain in
1023        // self.chains contains only Paged entries (no Resident).
1024        let policy = ForcePagePolicy::new();
1025        let pager = ColumnPager::new(policy.clone());
1026
1027        let mut b: ColumnMergeBatcher<(u64, u64), u64, i64> =
1028            differential_dataflow::trace::Batcher::new(None, 0);
1029        b.set_pager(pager);
1030
1031        // Push records straddling an upper of 5 — half should be kept, half
1032        // shipped. Use enough records to fill at least one chunk.
1033        let n: u64 = 200;
1034        for i in 0..n {
1035            let input = col(&[((i, 0), i % 10, 1)]);
1036            b.push_into(input);
1037        }
1038        let upper = Antichain::from_elem(5u64);
1039        let _ = differential_dataflow::trace::Batcher::seal(&mut b, upper);
1040
1041        // Anything kept (times >= 5) should be sitting in b.chains as paged.
1042        let kept_records: usize = b
1043            .chains
1044            .iter()
1045            .flat_map(|c| c.iter())
1046            .map(|p| match p {
1047                PagedColumn::Paged { meta, .. } => {
1048                    // Records aren't directly available here; sanity-check
1049                    // that no Resident snuck in.
1050                    let _ = meta;
1051                    1
1052                }
1053                PagedColumn::Compressed { meta, .. } => {
1054                    let _ = meta;
1055                    1
1056                }
1057                PagedColumn::Resident(_, _) => {
1058                    panic!("kept chain entry was Resident under ForcePagePolicy");
1059                }
1060            })
1061            .sum();
1062        // We expect *some* kept entries (times in [5..10) loop slot).
1063        assert!(kept_records > 0, "expected at least one kept paged entry");
1064        assert!(policy.out.load(std::sync::atomic::Ordering::Relaxed) > 0);
1065        let _ = n;
1066    }
1067}