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