Skip to main content

mz_timely_util/columnar/
chunk.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! [`ColumnChunk`]: differential's [`Chunk`] over [`Column`]-shaped updates.
11//!
12//! A chunk is a sorted, consolidated run of `(D, T, R)` updates in the flat
13//! columnar layout, in one of two homes:
14//!
15//! * **Resident**: an `Rc`-shared [`Column`] on the heap. Fresh input, merge
16//!   output, and small tails live here.
17//! * **Spilled**: the serialized body in the process [`Pool`], with the record
18//!   count and the first and last data items resident. The pool owns residency
19//!   from there, with slots under a memory budget and compression and device
20//!   pageout under pressure, and a body that dies before pressure reaches it
21//!   is freed without I/O.
22//!
23//! Reads of a spilled body are copy-out and scoped to the call that needs
24//! them: the body is read into caller-owned memory and no reference into pool
25//! memory ever exists outside the pool. That contract is what lets the pool
26//! evict with no reader accounting at all.
27//!
28//! Spilling happens in [`Chunk::settle`], the trait's designated commit point:
29//! chunks moved to settled output are handed to the pool when spilling is
30//! enabled (see [`set_compute_spill_enabled`] and [`set_storage_spill_enabled`]).
31//! The spill destination resolves per commit from three pieces of mutable
32//! state. A thread-local pool override, for tests and benches, wins outright.
33//! Otherwise the compute and storage gates, composed as an OR, route commits
34//! to the process pool installed by [`crate::pool_config`], and with no pool
35//! installed chunks stay resident. A second thread-local holds the reusable
36//! scratch that call-scoped reads of spilled bodies copy into.
37//! Grading is by serialized bytes, the ship size
38//! [`Column`] already targets, rather than by the record-count `TARGET`,
39//! since record count does not bound bytes for variable-width data.
40//!
41//! Chunks whose data is a `(key, val)` pair additionally implement
42//! [`UnloadChunk`], the bulk-read capability: sorted probe keys in, matching
43//! updates appended to caller-owned staging, with `locate` answered from the
44//! resident fence metadata so a probe set faults only the chunk bodies it
45//! actually touches.
46
47use std::cell::RefCell;
48use std::collections::VecDeque;
49use std::rc::Rc;
50use std::sync::atomic::{AtomicBool, Ordering};
51
52use columnar::bytes::indexed;
53use columnar::{Borrow, BorrowedOf, Columnar, Container as _, FromBytes, Index, Len, Push as _};
54use differential_dataflow::difference::Semigroup;
55use differential_dataflow::lattice::Lattice;
56use differential_dataflow::trace::chunk::Chunk;
57use mz_ore::cast::CastFrom;
58use mz_ore::pool::{ChunkHandle, ChunkHints, ExtentCodec, Pool};
59use timely::Accountable;
60use timely::container::{ContainerBuilder, PushInto};
61use timely::dataflow::channels::ContainerBytes;
62use timely::progress::Timestamp;
63use timely::progress::frontier::AntichainRef;
64
65use crate::columnar::batcher::{ColumnChunker, gallop};
66use crate::columnar::unload::UnloadChunk;
67use crate::columnar::{Column, at_serialized_capacity};
68
69/// Compute's leg of the process spill gate. See [`set_compute_spill_enabled`].
70static COMPUTE_SPILL_ENABLED: AtomicBool = AtomicBool::new(false);
71
72/// Storage's leg of the process spill gate. See [`set_storage_spill_enabled`].
73static STORAGE_SPILL_ENABLED: AtomicBool = AtomicBool::new(false);
74
75thread_local! {
76    /// A thread-scoped pool override, taking precedence over the global
77    /// enable flag and pool. Lets tests and benches spill through a private
78    /// pool without touching process-global state.
79    static SPILL_OVERRIDE: RefCell<Option<Pool>> = const { RefCell::new(None) };
80
81    /// Reusable staging for call-scoped reads of spilled bodies.
82    static READ_SCRATCH: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
83}
84
85/// Enable or disable chunk spilling on behalf of compute's arrangement
86/// batchers.
87///
88/// Chunks carry no subsystem identity, so the spill decision is process-wide:
89/// committed chunks spill while *either* the compute or the storage gate is
90/// set. Each subsystem's config application writes only its own gate, so the
91/// two dyncfg flags compose as an OR instead of clobbering each other.
92///
93/// Takes effect at the next `settle`. Already-spilled chunks are unaffected
94/// either way. The pool is resolved per commit through
95/// [`crate::pool_config::active_pool`], so chunks spill only once
96/// `apply_pool_config` has installed and budgeted the pool. With no pool
97/// installed chunks stay resident regardless of the gates.
98pub fn set_compute_spill_enabled(enabled: bool) {
99    COMPUTE_SPILL_ENABLED.store(enabled, Ordering::Relaxed);
100}
101
102/// Enable or disable chunk spilling on behalf of storage's upsert dataflows.
103///
104/// See [`set_compute_spill_enabled`] for the shared-gate semantics.
105pub fn set_storage_spill_enabled(enabled: bool) {
106    STORAGE_SPILL_ENABLED.store(enabled, Ordering::Relaxed);
107}
108
109/// Set or unset the pool through which this thread's chunk spills are
110/// routed, taking precedence over the gates and the process pool. `None`
111/// restores the global resolution.
112pub fn set_spill_override(pool: Option<Pool>) {
113    SPILL_OVERRIDE.with(|cell| *cell.borrow_mut() = pool);
114}
115
116/// The pool committed chunks spill to, if any.
117fn spill_pool() -> Option<Pool> {
118    if let Some(pool) = SPILL_OVERRIDE.with(|cell| cell.borrow().clone()) {
119        return Some(pool);
120    }
121    let enabled = COMPUTE_SPILL_ENABLED.load(Ordering::Relaxed)
122        || STORAGE_SPILL_ENABLED.load(Ordering::Relaxed);
123    if enabled {
124        crate::pool_config::active_pool()
125    } else {
126        None
127    }
128}
129
130/// Scratch capacity retained across reads, in words. A read larger than this
131/// releases the buffer afterward, so a thread's scratch does not ratchet to
132/// the largest body it ever carried (heap no pool gauge can see).
133const SCRATCH_RETAIN_WORDS: usize = 1 << 18;
134
135/// Run `f` with this thread's read scratch, cleared of any previous use.
136fn with_scratch<Out>(f: impl FnOnce(&mut Vec<u64>) -> Out) -> Out {
137    READ_SCRATCH.with(|cell| {
138        let mut scratch = cell.take();
139        scratch.clear();
140        let out = f(&mut scratch);
141        if scratch.capacity() > SCRATCH_RETAIN_WORDS {
142            scratch.clear();
143            scratch.shrink_to_fit();
144        }
145        cell.replace(scratch);
146        out
147    })
148}
149
150/// The serialized-byte size committed chunks aim for, matching the ship size
151/// of the columnar merge machinery.
152const COMMIT_BYTES: usize = 2 << 20;
153
154/// Bodies smaller than this stay resident: the pool's smallest size class is
155/// 64 KiB, so spilling below it trades no meaningful memory for slot waste.
156///
157/// Sub-floor bodies are invisible to the pool's budget, which is safe only
158/// while they are rare. `settle` coalesces toward `COMMIT_BYTES` before
159/// committing, so in the harness only a final `done` tail commits below the
160/// floor. A caller that commits many small chunks directly accumulates
161/// unbudgeted heap, and no accounting here would catch it.
162const SPILL_MIN_BYTES: usize = 64 << 10;
163
164/// Whether a column is big enough to commit on its own. A monotone
165/// threshold, so settle's carry, which grows by whole chunks, cannot step
166/// over it.
167fn at_commit_size<C: Columnar>(column: &Column<C>) -> bool {
168    column.length_in_bytes() >= COMMIT_BYTES - COMMIT_BYTES / 10
169}
170
171/// Reconstructs the borrowed columnar view from serialized words, the same
172/// zero-copy decode [`Column::borrow`] performs on its `Align` variant.
173fn borrow_words<C: Columnar>(words: &[u64]) -> BorrowedOf<'_, C> {
174    <BorrowedOf<'_, C>>::from_bytes(&mut indexed::decode(words))
175}
176
177/// Narrow a columnar ref to a shorter lifetime, so refs from different
178/// borrows, such as a probe column and a chunk's own columns, can be compared
179/// (the refs are lifetime-invariant).
180#[inline(always)]
181fn rr<'b, 'a: 'b, C: Columnar>(item: columnar::Ref<'a, C>) -> columnar::Ref<'b, C> {
182    columnar::ContainerOf::<C>::reborrow_ref(item)
183}
184
185/// A spilled chunk body: the serialized column in the pool, plus the resident
186/// metadata every [`Chunk`] must answer without fetching. That metadata is
187/// the record count and the first and last data items (the fence entries
188/// [`UnloadChunk::locate`] consults).
189pub struct SpilledBody<D: Columnar> {
190    /// Number of updates in the body.
191    records: usize,
192    /// The first and last data items, as a two-element container. One
193    /// container rather than two singletons, so the leaf allocations are not
194    /// duplicated per fence.
195    fences: D::Container,
196    /// The chunk's generational depth, mirrored into the pool's
197    /// [`ChunkHints`] at spill time.
198    depth: u8,
199    /// The pool chunk holding the serialized column.
200    handle: ChunkHandle,
201}
202
203/// A sorted, consolidated run of `(D, T, R)` updates, resident or spilled.
204///
205/// Every chunk carries a generational depth, fixed at creation: fresh chunks
206/// are depth 0, a merge output is one generation past its deepest input
207/// (saturating at `u8::MAX`, where remerged long-lived chunks stay), and
208/// rewrites within a generation (extract, advance, settle coalescing)
209/// preserve depth. At spill time the depth becomes the pool's [`ChunkHints`],
210/// so repeatedly merged (older, colder) data lands in deeper eviction bands.
211pub enum ColumnChunk<D: Columnar, T: Columnar, R: Columnar> {
212    /// Body on the heap, shared via `Rc`, with its generational depth.
213    Resident(Rc<Column<(D, T, R)>>, u8),
214    /// Body in the pool. See [`SpilledBody`].
215    Spilled(Rc<SpilledBody<D>>),
216}
217
218impl<D: Columnar, T: Columnar, R: Columnar> Clone for ColumnChunk<D, T, R> {
219    fn clone(&self) -> Self {
220        match self {
221            ColumnChunk::Resident(col, depth) => ColumnChunk::Resident(Rc::clone(col), *depth),
222            ColumnChunk::Spilled(body) => ColumnChunk::Spilled(Rc::clone(body)),
223        }
224    }
225}
226
227impl<D: Columnar, T: Columnar, R: Columnar> Default for ColumnChunk<D, T, R> {
228    fn default() -> Self {
229        ColumnChunk::Resident(Rc::new(Column::default()), 0)
230    }
231}
232
233impl<D: Columnar, T: Columnar, R: Columnar> Accountable for ColumnChunk<D, T, R> {
234    fn record_count(&self) -> i64 {
235        i64::try_from(self.records()).expect("record count fits i64")
236    }
237}
238
239impl<D: Columnar, T: Columnar, R: Columnar> ColumnChunk<D, T, R> {
240    /// Wrap a sorted, consolidated, non-empty column as a resident chunk of
241    /// the youngest generation.
242    pub fn from_column(column: Column<(D, T, R)>) -> Self {
243        mz_ore::soft_assert_no_log!(!column.is_empty(), "chunks must be non-empty");
244        ColumnChunk::Resident(Rc::new(column), 0)
245    }
246
247    /// The body as an owned column. A spilled body is copied out of the pool
248    /// within this call. A shared resident body is copied.
249    pub fn into_column(self) -> Column<(D, T, R)> {
250        match self {
251            ColumnChunk::Resident(col, _) => {
252                Rc::try_unwrap(col).unwrap_or_else(|shared| copy_column(&shared))
253            }
254            ColumnChunk::Spilled(body) => {
255                let mut words = Vec::new();
256                body.handle.read_into(&mut words);
257                Column::Align(words)
258            }
259        }
260    }
261
262    /// True when the body lives in the pool.
263    pub fn is_spilled(&self) -> bool {
264        matches!(self, ColumnChunk::Spilled(_))
265    }
266
267    /// The number of updates, from resident state only.
268    fn records(&self) -> usize {
269        match self {
270            ColumnChunk::Resident(col, _) => col.borrow().len(),
271            ColumnChunk::Spilled(body) => body.records,
272        }
273    }
274
275    /// The generational depth, from resident state only.
276    fn depth(&self) -> u8 {
277        match self {
278            ColumnChunk::Resident(_, depth) => *depth,
279            ColumnChunk::Spilled(body) => body.depth,
280        }
281    }
282
283    /// The first and last data items, from resident state only.
284    fn data_span(&self) -> (columnar::Ref<'_, D>, columnar::Ref<'_, D>) {
285        match self {
286            ColumnChunk::Resident(col, _) => {
287                let data = col.borrow().0;
288                (data.get(0), data.get(data.len() - 1))
289            }
290            ColumnChunk::Spilled(body) => {
291                let fences = body.fences.borrow();
292                (fences.get(0), fences.get(1))
293            }
294        }
295    }
296
297    /// Commit a non-empty column at the given generational depth: spill it to
298    /// the pool when spilling is on and the body is worth a slot, else keep it
299    /// resident.
300    fn commit(column: Column<(D, T, R)>, depth: u8) -> Self {
301        mz_ore::soft_assert_no_log!(!column.is_empty(), "chunks must be non-empty");
302        if let Some(pool) = spill_pool() {
303            if column.length_in_bytes() >= SPILL_MIN_BYTES {
304                return Self::spill_body(column, &pool, depth);
305            }
306        }
307        ColumnChunk::Resident(Rc::new(column), depth)
308    }
309
310    /// Spill a non-empty column into `pool` unconditionally, capturing the
311    /// resident fence metadata.
312    fn spill_body(column: Column<(D, T, R)>, pool: &Pool, depth: u8) -> Self {
313        let len_bytes = column.length_in_bytes();
314        let view = column.borrow();
315        let records = view.len();
316        let mut fences = D::Container::default();
317        fences.push(view.0.get(0));
318        fences.push(view.0.get(records - 1));
319        let handle = spill_column(column, pool, len_bytes, ChunkHints { depth });
320        ColumnChunk::Spilled(Rc::new(SpilledBody {
321            records,
322            fences,
323            depth,
324            handle,
325        }))
326    }
327}
328
329/// Copy a column into a fresh `Typed` column via bulk per-leaf extension.
330fn copy_column<C: Columnar>(column: &Column<C>) -> Column<C> {
331    let view = column.borrow();
332    let mut fresh = C::Container::default();
333    fresh.extend_from_self(view, 0..view.len());
334    Column::Typed(fresh)
335}
336
337/// The chunk-side [`ExtentCodec`]: a little-endian `u32` body-length prefix
338/// followed by one lz4 block, the framing
339/// `lz4_flex::block::compress_prepend_size` produces. Every chunk consumer
340/// passes [`LZ4_CODEC`] at insert; the pool itself has no codec opinion.
341#[derive(Debug)]
342pub struct Lz4Codec;
343
344/// The [`Lz4Codec`] instance chunk consumers pass to
345/// [`Pool::insert_with`].
346pub static LZ4_CODEC: Lz4Codec = Lz4Codec;
347
348impl ExtentCodec for Lz4Codec {
349    fn encode(&self, body: &[u8], out: &mut Vec<u8>) {
350        let max_out = lz4_flex::block::get_maximum_output_size(body.len());
351        out.resize(4 + max_out, 0);
352        let len = u32::try_from(body.len()).expect("chunk bodies are bounded by the size classes");
353        out[..4].copy_from_slice(&len.to_le_bytes());
354        let compressed = lz4_flex::block::compress_into(body, &mut out[4..])
355            .expect("output sized to the maximum");
356        out.truncate(4 + compressed);
357    }
358
359    fn decode(&self, stored: &[u8], body: &mut [u8]) {
360        let prefix: [u8; 4] = stored[..4].try_into().expect("prefix length");
361        let len = usize::try_from(u32::from_le_bytes(prefix)).expect("length fits usize");
362        assert_eq!(
363            len,
364            body.len(),
365            "destination must match the encoded body length"
366        );
367        let written = lz4_flex::block::decompress_into(&stored[4..], body)
368            .expect("stored bytes hold a valid lz4 block");
369        assert_eq!(written, body.len(), "decoded length mismatch");
370    }
371}
372
373/// Serialize a column into a pool slot. The `Align` variant is already the
374/// serialized form and copies in directly. Other variants write their
375/// [`ContainerBytes`] encoding through a cursor over the slot memory. Sizing
376/// is exact, so a short or overlong write is a contract violation and panics.
377fn spill_column<C: Columnar>(
378    column: Column<C>,
379    pool: &Pool,
380    len_bytes: usize,
381    hints: ChunkHints,
382) -> ChunkHandle {
383    mz_ore::soft_assert_eq_no_log!(len_bytes % 8, 0);
384    match column {
385        Column::Align(words) => pool.insert_with(words.len(), hints, &LZ4_CODEC, |dst| {
386            dst.copy_from_slice(&words)
387        }),
388        other => pool.insert_with(len_bytes / 8, hints, &LZ4_CODEC, |dst| {
389            let bytes: &mut [u8] = bytemuck::cast_slice_mut(dst);
390            let mut cursor = std::io::Cursor::new(bytes);
391            other.into_bytes(&mut cursor);
392            assert_eq!(
393                usize::try_from(cursor.position()).expect("usize position"),
394                len_bytes,
395                "serialized body must fill the chunk exactly",
396            );
397        }),
398    }
399}
400
401/// A column is `Typed`, or becomes one by copy. Merge and settle accumulate
402/// into `Typed` targets. Serialized variants arrive from spill reads and
403/// remote channels.
404fn to_typed<C: Columnar>(column: Column<C>) -> Column<C> {
405    match column {
406        typed @ Column::Typed(_) => typed,
407        other => copy_column(&other),
408    }
409}
410
411impl<D, T, R> Chunk for ColumnChunk<D, T, R>
412where
413    D: Columnar,
414    for<'a> columnar::Ref<'a, D>: Copy + Ord,
415    T: Columnar + Default + Timestamp + Lattice + Ord,
416    for<'a> columnar::Ref<'a, T>: Copy + Ord,
417    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
418{
419    type Time = T;
420
421    /// A nominal record count for the harness's fuel and ladder accounting,
422    /// not a bound. Actual chunk sizing is by serialized bytes: `merge` and
423    /// `extract` cut output at the [`Column`] ship threshold, and `settle`
424    /// grades by `at_commit_size`, so a chunk of narrow records can hold more
425    /// records than this and nothing here consults it.
426    const TARGET: usize = 65536;
427
428    fn len(&self) -> usize {
429        self.records()
430    }
431
432    /// [`Column::merge_from`] does the work: gallop bulk-copies for disjoint
433    /// runs, semigroup consolidation on equal `(data, time)`, output cut at
434    /// the ship threshold. A survivor pushed back untouched keeps its
435    /// original form, in particular a spilled body is neither rebuilt nor
436    /// re-spilled.
437    ///
438    /// Fronts whose data ranges are disjoint never load at all: the resident
439    /// fence entries decide, and the lower front moves to the output verbatim.
440    fn merge(in1: &mut VecDeque<Self>, in2: &mut VecDeque<Self>, out: &mut VecDeque<Self>) {
441        // Disjoint fast path: when one front lies strictly below the other's
442        // first data item (equal boundary data could still interleave on
443        // time), the merged prefix through the shared horizon is exactly that
444        // front, unchanged.
445        let (a_first, a_last) = in1
446            .front()
447            .expect("caller guarantees non-empty input")
448            .data_span();
449        let (b_first, b_last) = in2
450            .front()
451            .expect("caller guarantees non-empty input")
452            .data_span();
453        let a_low = rr::<D>(a_last) < rr::<D>(b_first);
454        let b_low = rr::<D>(b_last) < rr::<D>(a_first);
455        if a_low {
456            out.push_back(in1.pop_front().expect("front observed above"));
457            return;
458        }
459        if b_low {
460            out.push_back(in2.pop_front().expect("front observed above"));
461            return;
462        }
463
464        let a = in1.pop_front().expect("caller guarantees non-empty input");
465        let b = in2.pop_front().expect("caller guarantees non-empty input");
466        // Merged output is one generation past its deepest input. A survivor
467        // (untouched or rewritten from its remainder) keeps its own depth.
468        let depths = [a.depth(), b.depth()];
469        let out_depth = depths[0].max(depths[1]).saturating_add(1);
470        let mut spill_a = match &a {
471            ColumnChunk::Spilled(body) => Some(Rc::clone(body)),
472            ColumnChunk::Resident(_, _) => None,
473        };
474        let mut spill_b = match &b {
475            ColumnChunk::Spilled(body) => Some(Rc::clone(body)),
476            ColumnChunk::Resident(_, _) => None,
477        };
478        let mut cols = [a.into_column(), b.into_column()];
479        let mut positions = [0usize, 0usize];
480        loop {
481            let mut result: Column<(D, T, R)> = Column::default();
482            let yielded = result.merge_from(&mut cols, &mut positions);
483            if !result.is_empty() {
484                out.push_back(ColumnChunk::Resident(Rc::new(result), out_depth));
485            }
486            if !yielded {
487                break;
488            }
489        }
490        let [col_a, col_b] = &mut cols;
491        // Per input side: the loaded column and the merge's consumed position
492        // within it, the side's pre-merge depth, its original spilled body
493        // when it had one, and the deque a survivor returns to.
494        for (col, pos, depth, spilled, queue) in [
495            (col_a, positions[0], depths[0], &mut spill_a, in1),
496            (col_b, positions[1], depths[1], &mut spill_b, in2),
497        ] {
498            let len = col.borrow().len();
499            if pos == 0 && len > 0 {
500                // Untouched survivor: restore it as it was, spilled bodies
501                // included (the loaded copy is dropped).
502                let chunk = match spilled.take() {
503                    Some(body) => ColumnChunk::Spilled(body),
504                    None => ColumnChunk::Resident(Rc::new(std::mem::take(col)), depth),
505                };
506                queue.push_front(chunk);
507            } else if pos < len {
508                let view = col.borrow();
509                let mut rest = <(D, T, R) as Columnar>::Container::default();
510                rest.extend_from_self(view, pos..len);
511                queue.push_front(ColumnChunk::Resident(Rc::new(Column::Typed(rest)), depth));
512            }
513        }
514    }
515
516    /// Partition one front chunk by `frontier`, folding kept times into
517    /// `residual`. One chunk per call, so the harness settles both sides
518    /// between chunks. Output is cut at the ship threshold.
519    fn extract(
520        input: &mut VecDeque<Self>,
521        frontier: AntichainRef<T>,
522        residual: &mut timely::progress::Antichain<T>,
523        keep: &mut VecDeque<Self>,
524        ship: &mut VecDeque<Self>,
525    ) {
526        let Some(chunk) = input.pop_front() else {
527            return;
528        };
529        // Partitioning rewrites within a generation, so both sides keep the
530        // input chunk's depth.
531        let depth = chunk.depth();
532        let mut col = chunk.into_column();
533        let len = col.borrow().len();
534        let mut pos = 0;
535        let mut keep_col: Column<(D, T, R)> = Column::default();
536        let mut ship_col: Column<(D, T, R)> = Column::default();
537        // TODO: rewrite the underlying `Column::extract` as two passes, the
538        // time column first to find run boundaries, then bulk per-range
539        // copies of the remaining leaves.
540        // Move a side's accumulation to its queue, at the ship threshold
541        // mid-loop, or any non-empty remainder at the end.
542        let cut = |col: &mut Column<(D, T, R)>, queue: &mut VecDeque<Self>, force: bool| {
543            if !col.is_empty() && (force || at_serialized_capacity(&col.borrow())) {
544                queue.push_back(ColumnChunk::Resident(Rc::new(std::mem::take(col)), depth));
545            }
546        };
547        while pos < len {
548            col.extract(&mut pos, frontier, residual, &mut keep_col, &mut ship_col);
549            if pos < len {
550                cut(&mut keep_col, keep, false);
551                cut(&mut ship_col, ship, false);
552            }
553        }
554        cut(&mut keep_col, keep, true);
555        cut(&mut ship_col, ship, true);
556    }
557
558    /// Advance times by `frontier` and consolidate, withholding the trailing
559    /// `D` group as the carry unless `done` (its updates may continue in input
560    /// this call has not seen).
561    ///
562    /// The input concatenates into the carry's container, so a group that
563    /// grows across many calls is appended to, not rebuilt. Each record is
564    /// copied once on arrival, keeping the run linear. Advancing is
565    /// lattice-monotone but not order-monotone, so each group's advanced
566    /// times are re-sorted before adjacent equal times fold.
567    fn advance(
568        input: &mut VecDeque<Self>,
569        frontier: AntichainRef<T>,
570        done: bool,
571        out: &mut VecDeque<Self>,
572    ) {
573        let Some(front) = input.pop_front() else {
574            return;
575        };
576        // Advancing rewrites within a generation, so output and carry keep
577        // the deepest input depth. Only merges increment.
578        let mut depth = front.depth();
579        // Concatenate the input into one column, reusing the front chunk's
580        // storage when it is exclusively owned (the usual case: it is last
581        // call's carry).
582        let mut base = to_typed(front.into_column());
583        {
584            let Column::Typed(base_c) = &mut base else {
585                unreachable!("to_typed returns Typed");
586            };
587            for chunk in input.drain(..) {
588                depth = depth.max(chunk.depth());
589                let col = chunk.into_column();
590                let view = col.borrow();
591                base_c.extend_from_self(view, 0..view.len());
592            }
593        }
594        let view = base.borrow();
595        let total = view.len();
596        if total == 0 {
597            return;
598        }
599        let data = view.0;
600
601        // Giant-group early-out: if the whole input is one `D` group, nothing
602        // is provably complete. Unless `done`, push it all back as the carry.
603        if !done && data.get(0) == data.get(total - 1) {
604            input.push_front(ColumnChunk::Resident(Rc::new(base), depth));
605            return;
606        }
607
608        // The processing bound: everything, or everything before the trailing
609        // `D` group when it must be withheld.
610        let end = if done {
611            total
612        } else {
613            let last = data.get(total - 1);
614            let mut end = total - 1;
615            while end > 0 && data.get(end - 1) == last {
616                end -= 1;
617            }
618            end
619        };
620
621        let mut result = <(D, T, R) as Columnar>::Container::default();
622        // Per-group scratch: advanced owned times with owned diffs.
623        let mut scratch: Vec<(T, R)> = Vec::new();
624        let mut index = 0;
625        // Cut output at the commit size, checked amortized by emitted records
626        // (the size test walks the container's leaves, so probing it per
627        // record would be quadratic). Records, not groups: a single group may
628        // carry arbitrarily many advanced times, and a cut is legal anywhere
629        // in the sorted sequence, so bounding by records keeps the largest
630        // possible output chunk within one check period of the target. It
631        // must not outgrow the pool's largest size class, past which a body
632        // degrades to a permanently resident heap chunk.
633        const CUT_CHECK_RECORDS: usize = 1024;
634        let mut records_since_check = 0usize;
635        // TODO: the output leaves are addressed independently, so a group
636        // that folds nothing (no time collisions, no zeroed diffs) could bulk
637        // `extend_from_self` the D leaf over the whole group range and push
638        // only the advanced times and diffs per record, and a singleton group
639        // (the common case for mostly-unique D) could skip the scratch and
640        // sort round trip entirely.
641        while index < end {
642            let group_d = data.get(index);
643            scratch.clear();
644            while index < end && data.get(index) == group_d {
645                let (_, t, r) = view.get(index);
646                let mut owned_t = T::into_owned(t);
647                owned_t.advance_by(frontier);
648                scratch.push((owned_t, R::into_owned(r)));
649                index += 1;
650            }
651            scratch.sort_by(|a, b| a.0.cmp(&b.0));
652            let mut run = scratch.drain(..).peekable();
653            while let Some((t, mut r)) = run.next() {
654                while run.peek().is_some_and(|(t2, _)| *t2 == t) {
655                    let (_, r2) = run.next().expect("peeked");
656                    r.plus_equals(&r2);
657                }
658                if !r.is_zero() {
659                    result.0.push(group_d);
660                    result.1.push(&t);
661                    result.2.push(&r);
662                    records_since_check += 1;
663                    if records_since_check >= CUT_CHECK_RECORDS {
664                        records_since_check = 0;
665                        if u64::cast_from(indexed::length_in_words(&result.borrow()))
666                            >= u64::cast_from(COMMIT_BYTES / 8)
667                        {
668                            out.push_back(ColumnChunk::Resident(
669                                Rc::new(Column::Typed(std::mem::take(&mut result))),
670                                depth,
671                            ));
672                        }
673                    }
674                }
675            }
676        }
677        if !result.is_empty() {
678            out.push_back(ColumnChunk::Resident(Rc::new(Column::Typed(result)), depth));
679        }
680
681        // Rebuild the withheld trailing group as the carry.
682        if end < total {
683            let mut carry = <(D, T, R) as Columnar>::Container::default();
684            carry.extend_from_self(view, end..total);
685            input.push_front(ColumnChunk::Resident(Rc::new(Column::Typed(carry)), depth));
686        }
687    }
688
689    /// Grade by serialized bytes and commit: spilled chunks pass through
690    /// untouched, resident chunks at the commit size commit as they are, and
691    /// smaller neighbors coalesce until the accumulation reaches it.
692    /// Committing is the spill hook (see `ColumnChunk::commit`). A
693    /// sub-threshold tail is withheld as the carry unless `done`.
694    fn settle(input: &mut VecDeque<Self>, done: bool, out: &mut VecDeque<Self>) {
695        // Coalescing rewrites within a generation, so the carry commits at
696        // the deepest depth among its constituent chunks.
697        let mut carry: Option<(Column<(D, T, R)>, u8)> = None;
698        while let Some(chunk) = input.pop_front() {
699            let (rc, depth) = match chunk {
700                spilled @ ColumnChunk::Spilled(_) => {
701                    if let Some((col, depth)) = carry.take() {
702                        out.push_back(ColumnChunk::commit(col, depth));
703                    }
704                    out.push_back(spilled);
705                    continue;
706                }
707                ColumnChunk::Resident(rc, depth) => (rc, depth),
708            };
709            let full = at_commit_size(&rc);
710            // A sub-threshold chunk coalesces into the open carry by borrow,
711            // never unwrapping a shared body.
712            if !full && let Some((mut acc, acc_depth)) = carry.take() {
713                let Column::Typed(acc_c) = &mut acc else {
714                    unreachable!("carry is always Typed");
715                };
716                let view = rc.borrow();
717                acc_c.extend_from_self(view, 0..view.len());
718                let acc_depth = acc_depth.max(depth);
719                if at_commit_size(&acc) {
720                    out.push_back(ColumnChunk::commit(acc, acc_depth));
721                } else {
722                    carry = Some((acc, acc_depth));
723                }
724                continue;
725            }
726            // Otherwise any open carry flushes, and the chunk either commits
727            // whole or opens the next carry.
728            if let Some((acc, acc_depth)) = carry.take() {
729                out.push_back(ColumnChunk::commit(acc, acc_depth));
730            }
731            let col = Rc::try_unwrap(rc).unwrap_or_else(|rc| copy_column(&rc));
732            if full {
733                out.push_back(ColumnChunk::commit(col, depth));
734            } else {
735                carry = Some((to_typed(col), depth));
736            }
737        }
738        if let Some((col, depth)) = carry {
739            if done {
740                out.push_back(ColumnChunk::commit(col, depth));
741            } else {
742                input.push_front(ColumnChunk::Resident(Rc::new(col), depth));
743            }
744        }
745    }
746}
747
748/// Append every update in `view` whose key matches a probe at or after
749/// `*probe_index` into `staging`, per the [`UnloadChunk`] consume-index
750/// protocol: probes strictly below the view's last key are consumed, a probe
751/// equal to it is extracted but left for the next chunk.
752fn extract_view_into<'v, 'p, K, V, T, R>(
753    view: BorrowedOf<'v, ((K, V), T, R)>,
754    probes: BorrowedOf<'p, K>,
755    probe_index: &mut usize,
756    staging: &mut <((K, V), T, R) as Columnar>::Container,
757) where
758    K: Columnar,
759    V: Columnar,
760    T: Columnar,
761    R: Columnar,
762    for<'b> columnar::Ref<'b, K>: Copy + Ord,
763{
764    let keys = view.0.0;
765    let len = keys.len();
766    let last = keys.get(len - 1);
767    let count = probes.len();
768    let mut pos = 0;
769    while *probe_index < count {
770        let probe = probes.get(*probe_index);
771        mz_ore::soft_assert_no_log!(
772            *probe_index == 0 || rr::<K>(probes.get(*probe_index - 1)) < rr::<K>(probe),
773            "probe keys must be sorted and deduplicated"
774        );
775        if rr::<K>(probe) > rr::<K>(last) {
776            return;
777        }
778        gallop(len, &mut pos, |i| rr::<K>(keys.get(i)) < rr::<K>(probe));
779        let start = pos;
780        while pos < len && rr::<K>(keys.get(pos)) == rr::<K>(probe) {
781            pos += 1;
782        }
783        staging.extend_from_self(view, start..pos);
784        if rr::<K>(probe) == rr::<K>(last) {
785            return;
786        }
787        *probe_index += 1;
788    }
789}
790
791impl<K, V, T, R> UnloadChunk for ColumnChunk<(K, V), T, R>
792where
793    K: Columnar,
794    for<'a> columnar::Ref<'a, K>: Copy + Ord,
795    V: Columnar,
796    for<'a> columnar::Ref<'a, V>: Copy + Ord,
797    T: Columnar + Default + Timestamp + Lattice + Ord,
798    for<'a> columnar::Ref<'a, T>: Copy + Ord,
799    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
800{
801    /// The flat columnar accumulation. Appends are bulk column-range copies,
802    /// and a group straddling chunks stitches by plain concatenation.
803    type Staging = <((K, V), T, R) as Columnar>::Container;
804
805    /// A borrowed key column, e.g. of a `Column<K>` the consumer assembled
806    /// from its sorted, deduplicated probe keys.
807    type Probes<'a> = BorrowedOf<'a, K>;
808
809    fn probe_count(probes: Self::Probes<'_>) -> usize {
810        probes.len()
811    }
812
813    fn locate(&self, probes: Self::Probes<'_>, probe_index: usize) -> std::cmp::Ordering {
814        let probe = probes.get(probe_index);
815        // A data ref is a `(key ref, val ref)` tuple, so the key fences are a
816        // projection of the data fences.
817        let (first, last) = self.data_span();
818        let (first, last) = (first.0, last.0);
819        if rr::<K>(probe) < rr::<K>(first) {
820            std::cmp::Ordering::Less
821        } else if rr::<K>(probe) > rr::<K>(last) {
822            std::cmp::Ordering::Greater
823        } else {
824            std::cmp::Ordering::Equal
825        }
826    }
827
828    fn extract_into(
829        &self,
830        probes: Self::Probes<'_>,
831        probe_index: &mut usize,
832        staging: &mut Self::Staging,
833    ) {
834        match self {
835            ColumnChunk::Resident(col, _) => {
836                extract_view_into::<K, V, T, R>(col.borrow(), probes, probe_index, staging);
837            }
838            ColumnChunk::Spilled(body) => with_scratch(|scratch| {
839                // NOTE: deliberately the non-admitting read. One probe set
840                // touching a chunk is weak evidence it will be touched again,
841                // and probing a spilled trace must not accrete it back into
842                // residency. The cost is a full decode per probe set against
843                // an evicted chunk.
844                body.handle.read_into(scratch);
845                let view = borrow_words::<((K, V), T, R)>(scratch);
846                extract_view_into::<K, V, T, R>(view, probes, probe_index, staging);
847            }),
848        }
849    }
850
851    fn fetch_into(&self, staging: &mut Self::Staging) {
852        match self {
853            ColumnChunk::Resident(col, _) => {
854                let view = col.borrow();
855                staging.extend_from_self(view, 0..view.len());
856            }
857            ColumnChunk::Spilled(body) => with_scratch(|scratch| {
858                body.handle.read_into(scratch);
859                let view = borrow_words::<((K, V), T, R)>(scratch);
860                staging.extend_from_self(view, 0..view.len());
861            }),
862        }
863    }
864}
865
866/// A batch builder over [`ColumnChunk`] input that delegates to a builder
867/// over [`Column`] input, loading each chunk's body as it is pushed.
868///
869/// This is the adapter that lets a [`ChunkBatcher`] feed the existing
870/// column-input batch builders (and through them the existing spine layouts):
871/// the batcher's chains carry pool-spillable chunks, and bodies are read back
872/// copy-out only at the seal, one chunk at a time.
873///
874/// [`ChunkBatcher`]: differential_dataflow::trace::chunk::ChunkBatcher
875pub struct UnchunkBuilder<Bu, D: Columnar, T: Columnar, R: Columnar> {
876    inner: Bu,
877    _marker: std::marker::PhantomData<(D, T, R)>,
878}
879
880impl<Bu, D, T, R> differential_dataflow::trace::Builder for UnchunkBuilder<Bu, D, T, R>
881where
882    Bu: differential_dataflow::trace::Builder<Input = Column<(D, T, R)>>,
883    D: Columnar + 'static,
884    T: Columnar + 'static,
885    R: Columnar + 'static,
886{
887    type Input = ColumnChunk<D, T, R>;
888    type Time = Bu::Time;
889    type Output = Bu::Output;
890
891    fn with_capacity(keys: usize, vals: usize, upds: usize) -> Self {
892        Self {
893            inner: Bu::with_capacity(keys, vals, upds),
894            _marker: std::marker::PhantomData,
895        }
896    }
897
898    fn push(&mut self, chunk: &mut Self::Input) {
899        let mut column = std::mem::take(chunk).into_column();
900        self.inner.push(&mut column);
901    }
902
903    fn done(
904        self,
905        description: differential_dataflow::trace::Description<Self::Time>,
906    ) -> Self::Output {
907        self.inner.done(description)
908    }
909
910    fn seal(
911        chain: &mut Vec<Self::Input>,
912        description: differential_dataflow::trace::Description<Self::Time>,
913    ) -> Self::Output {
914        // One chunk at a time through `push`, so peak transient memory is a
915        // single loaded body rather than the whole chain at once.
916        let mut builder = Self::new();
917        for chunk in chain.iter_mut() {
918            builder.push(chunk);
919        }
920        chain.clear();
921        builder.done(description)
922    }
923}
924
925/// A chunker for `arrange_core` over [`ColumnChunk`]s: sorts and consolidates
926/// raw input columns through a [`ColumnChunker`] and wraps its output chunks.
927pub struct ChunkChunker<D: Columnar, T: Columnar, R: Columnar> {
928    inner: ColumnChunker<(D, T, R)>,
929    staged: ColumnChunk<D, T, R>,
930}
931
932impl<D, T, R> Default for ChunkChunker<D, T, R>
933where
934    D: Columnar,
935    T: Columnar,
936    R: Columnar,
937    ColumnChunker<(D, T, R)>: Default,
938{
939    fn default() -> Self {
940        Self {
941            inner: Default::default(),
942            staged: Default::default(),
943        }
944    }
945}
946
947impl<'a, D, T, R> PushInto<&'a mut Column<(D, T, R)>> for ChunkChunker<D, T, R>
948where
949    D: Columnar,
950    T: Columnar,
951    R: Columnar,
952    ColumnChunker<(D, T, R)>: PushInto<&'a mut Column<(D, T, R)>>,
953{
954    fn push_into(&mut self, item: &'a mut Column<(D, T, R)>) {
955        self.inner.push_into(item);
956    }
957}
958
959impl<D, T, R> ContainerBuilder for ChunkChunker<D, T, R>
960where
961    D: Columnar + 'static,
962    T: Columnar + 'static,
963    R: Columnar + 'static,
964    ColumnChunker<(D, T, R)>: ContainerBuilder<Container = Column<(D, T, R)>>,
965{
966    type Container = ColumnChunk<D, T, R>;
967
968    fn extract(&mut self) -> Option<&mut Self::Container> {
969        let col = self.inner.extract()?;
970        self.staged = ColumnChunk::from_column(std::mem::take(col));
971        Some(&mut self.staged)
972    }
973
974    fn finish(&mut self) -> Option<&mut Self::Container> {
975        let col = self.inner.finish()?;
976        self.staged = ColumnChunk::from_column(std::mem::take(col));
977        Some(&mut self.staged)
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    //! Property tests for the [`Chunk`] and [`UnloadChunk`] contracts on
984    //! [`ColumnChunk`].
985    //!
986    //! Strategy: generate sorted+consolidated inputs (the chunk invariant),
987    //! drive the trait methods the way the differential harness does, and
988    //! compare against brute-force references on owned tuples. Test types are
989    //! `D = (u64, u64)`, `T = u64`, `R = i64` from small ranges so equal-key
990    //! collisions are common and consolidation actually runs.
991
992    use differential_dataflow::trace::chunk::{ChunkBatch, ChunkBatcher};
993    use differential_dataflow::trace::{Batcher, Description};
994    use mz_ore::pool::Pool;
995    use proptest::prelude::*;
996    use timely::container::PushInto;
997    use timely::progress::Antichain;
998
999    use crate::columnar::unload::UnloadBatch;
1000
1001    use super::*;
1002
1003    type Tuple = ((u64, u64), u64, i64);
1004    type TestChunk = ColumnChunk<(u64, u64), u64, i64>;
1005
1006    /// The delegated codec's stored form is byte-identical to the extent
1007    /// store's previous hard-coded framing: a little-endian `u32`
1008    /// body-length prefix followed by one lz4 block, which is exactly what
1009    /// `compress_prepend_size` produces.
1010    #[mz_ore::test]
1011    fn lz4_codec_matches_the_previous_extent_framing() {
1012        let body: Vec<u8> = (0..100_000u32).flat_map(|i| i.to_le_bytes()).collect();
1013        let mut stored = Vec::new();
1014        LZ4_CODEC.encode(&body, &mut stored);
1015        assert_eq!(stored, lz4_flex::block::compress_prepend_size(&body));
1016        let mut round = vec![0u8; body.len()];
1017        LZ4_CODEC.decode(&stored, &mut round);
1018        assert_eq!(round, body);
1019    }
1020
1021    #[mz_ore::test]
1022    #[should_panic(expected = "destination must match")]
1023    fn lz4_codec_decode_length_mismatch_panics() {
1024        let mut stored = Vec::new();
1025        LZ4_CODEC.encode(&[7u8; 64], &mut stored);
1026        let mut short = vec![0u8; 32];
1027        LZ4_CODEC.decode(&stored, &mut short);
1028    }
1029
1030    /// Reference consolidation: sort by `(data, time)`, sum diffs over equal
1031    /// pairs, drop zeros.
1032    fn consolidate(mut v: Vec<Tuple>) -> Vec<Tuple> {
1033        v.sort();
1034        let mut out: Vec<Tuple> = Vec::new();
1035        for (d, t, r) in v {
1036            if let Some(last) = out.last_mut() {
1037                if last.0 == d && last.1 == t {
1038                    last.2 += r;
1039                    continue;
1040                }
1041            }
1042            out.push((d, t, r));
1043        }
1044        out.retain(|x| x.2 != 0);
1045        out
1046    }
1047
1048    fn arb_consolidated() -> impl Strategy<Value = Vec<Tuple>> {
1049        prop::collection::vec(((0u64..5, 0u64..5), 0u64..4, -3i64..=3i64), 0..40)
1050            .prop_map(consolidate)
1051    }
1052
1053    fn build_column(v: &[Tuple]) -> Column<Tuple> {
1054        let mut col: Column<Tuple> = Default::default();
1055        for tup in v {
1056            col.push_into(*tup);
1057        }
1058        col
1059    }
1060
1061    fn collect_column(col: &Column<Tuple>) -> Vec<Tuple> {
1062        col.borrow()
1063            .into_index_iter()
1064            .map(|((k, v), t, r)| {
1065                (
1066                    (u64::into_owned(k), u64::into_owned(v)),
1067                    u64::into_owned(t),
1068                    i64::into_owned(r),
1069                )
1070            })
1071            .collect()
1072    }
1073
1074    fn collect_chunks(chunks: impl IntoIterator<Item = TestChunk>) -> Vec<Tuple> {
1075        chunks
1076            .into_iter()
1077            .flat_map(|chunk| collect_column(&chunk.into_column()))
1078            .collect()
1079    }
1080
1081    fn collect_staging(staging: &<Tuple as Columnar>::Container) -> Vec<Tuple> {
1082        staging
1083            .borrow()
1084            .into_index_iter()
1085            .map(|((k, v), t, r)| {
1086                (
1087                    (u64::into_owned(k), u64::into_owned(v)),
1088                    u64::into_owned(t),
1089                    i64::into_owned(r),
1090                )
1091            })
1092            .collect()
1093    }
1094
1095    /// Cut consolidated data into non-empty chunks at the given points.
1096    fn chunked(data: &[Tuple], cuts: &[usize]) -> VecDeque<TestChunk> {
1097        let mut chunks = VecDeque::new();
1098        let mut start = 0;
1099        for cut in cuts {
1100            let end = (start + 1 + cut % 7).min(data.len());
1101            if end > start {
1102                chunks.push_back(ColumnChunk::from_column(build_column(&data[start..end])));
1103                start = end;
1104            }
1105        }
1106        if start < data.len() {
1107            chunks.push_back(ColumnChunk::from_column(build_column(&data[start..])));
1108        }
1109        chunks
1110    }
1111
1112    /// The chunked cut, with every chunk force-spilled through a private pool
1113    /// (bounds captured, bodies in the pool) regardless of size thresholds.
1114    fn chunked_spilled(data: &[Tuple], cuts: &[usize], pool: &Pool) -> VecDeque<TestChunk> {
1115        chunked(data, cuts)
1116            .into_iter()
1117            .map(|chunk| force_spill(chunk, pool))
1118            .collect()
1119    }
1120
1121    /// Spill one chunk through `pool`, bypassing the size threshold and
1122    /// keeping the chunk's depth.
1123    fn force_spill(chunk: TestChunk, pool: &Pool) -> TestChunk {
1124        let depth = chunk.depth();
1125        TestChunk::spill_body(chunk.into_column(), pool, depth)
1126    }
1127
1128    /// A single pool shared by every test in the module. A pool reserves a
1129    /// large slab of address space, so one per test (let alone per proptest
1130    /// case) exhausts the VM map under parallel test threads.
1131    fn test_pool() -> Pool {
1132        static POOL: std::sync::OnceLock<Pool> = std::sync::OnceLock::new();
1133        POOL.get_or_init(|| Pool::new().expect("pool creation"))
1134            .clone()
1135    }
1136
1137    proptest! {
1138        /// A full batcher round trip: push chunked inputs, seal everything,
1139        /// and compare with the reference consolidation of the union.
1140        #[mz_ore::test]
1141        #[cfg_attr(miri, ignore)]
1142        fn batcher_round_trip(
1143            inputs in prop::collection::vec(arb_consolidated(), 1..6),
1144            cuts in prop::collection::vec(0usize..7, 0..8),
1145        ) {
1146            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1147            let mut union = Vec::new();
1148            for input in &inputs {
1149                Extend::extend(&mut union, input.iter().copied());
1150                for chunk in chunked(input, &cuts) {
1151                    batcher.push_into(chunk);
1152                }
1153            }
1154            // An empty upper ships everything.
1155            let (sealed, _description) = batcher.seal(Antichain::new());
1156            prop_assert_eq!(collect_chunks(sealed), consolidate(union));
1157        }
1158
1159        /// The same round trip over force-spilled inputs: merge and extract
1160        /// read bodies back from the pool call-scoped.
1161        #[mz_ore::test]
1162        #[cfg_attr(miri, ignore)]
1163        fn batcher_round_trip_spilled(
1164            inputs in prop::collection::vec(arb_consolidated(), 1..4),
1165            cuts in prop::collection::vec(0usize..7, 0..6),
1166        ) {
1167            let pool = test_pool();
1168            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1169            let mut union = Vec::new();
1170            for input in &inputs {
1171                Extend::extend(&mut union, input.iter().copied());
1172                for chunk in chunked_spilled(input, &cuts, &pool) {
1173                    batcher.push_into(chunk);
1174                }
1175            }
1176            let (sealed, _description) = batcher.seal(Antichain::new());
1177            prop_assert_eq!(collect_chunks(sealed), consolidate(union));
1178        }
1179
1180        /// Sealing at an intermediate upper partitions by time and reports
1181        /// the kept lower envelope as the frontier.
1182        #[mz_ore::test]
1183        #[cfg_attr(miri, ignore)]
1184        fn seal_partitions_by_time(
1185            input in arb_consolidated(),
1186            cuts in prop::collection::vec(0usize..7, 0..8),
1187            upper in 0u64..5,
1188        ) {
1189            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1190            for chunk in chunked(&input, &cuts) {
1191                batcher.push_into(chunk);
1192            }
1193            let (shipped, _) = batcher.seal(Antichain::from_elem(upper));
1194            let expected_shipped: Vec<Tuple> =
1195                input.iter().copied().filter(|(_, t, _)| *t < upper).collect();
1196            prop_assert_eq!(collect_chunks(shipped), consolidate(expected_shipped));
1197
1198            let kept_min = input.iter().filter(|(_, t, _)| *t >= upper).map(|(_, t, _)| *t).min();
1199            let frontier = batcher.frontier().to_owned();
1200            prop_assert_eq!(frontier.elements().first().copied(), kept_min);
1201
1202            let (rest, _) = batcher.seal(Antichain::new());
1203            let expected_rest: Vec<Tuple> =
1204                input.iter().copied().filter(|(_, t, _)| *t >= upper).collect();
1205            prop_assert_eq!(collect_chunks(rest), consolidate(expected_rest));
1206        }
1207
1208        /// The intermediate-upper partition of `seal_partitions_by_time`, over
1209        /// force-spilled inputs: bodies read back from the pool and split by
1210        /// time in one seal.
1211        #[mz_ore::test]
1212        #[cfg_attr(miri, ignore)]
1213        fn seal_partitions_by_time_spilled(
1214            input in arb_consolidated(),
1215            cuts in prop::collection::vec(0usize..7, 0..8),
1216            upper in 0u64..5,
1217        ) {
1218            let pool = test_pool();
1219            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1220            for chunk in chunked_spilled(&input, &cuts, &pool) {
1221                batcher.push_into(chunk);
1222            }
1223            let (shipped, _) = batcher.seal(Antichain::from_elem(upper));
1224            let expected_shipped: Vec<Tuple> =
1225                input.iter().copied().filter(|(_, t, _)| *t < upper).collect();
1226            prop_assert_eq!(collect_chunks(shipped), consolidate(expected_shipped));
1227
1228            let kept_min = input.iter().filter(|(_, t, _)| *t >= upper).map(|(_, t, _)| *t).min();
1229            let frontier = batcher.frontier().to_owned();
1230            prop_assert_eq!(frontier.elements().first().copied(), kept_min);
1231
1232            let (rest, _) = batcher.seal(Antichain::new());
1233            let expected_rest: Vec<Tuple> =
1234                input.iter().copied().filter(|(_, t, _)| *t >= upper).collect();
1235            prop_assert_eq!(collect_chunks(rest), consolidate(expected_rest));
1236        }
1237
1238        /// `advance` equals per-record time advancement plus reference
1239        /// consolidation, including across a `done = false` carry.
1240        #[mz_ore::test]
1241        #[cfg_attr(miri, ignore)]
1242        fn advance_matches_reference(
1243            input in arb_consolidated(),
1244            cuts in prop::collection::vec(0usize..7, 0..8),
1245            frontier_elem in 0u64..5,
1246        ) {
1247            let frontier = Antichain::from_elem(frontier_elem);
1248            let mut chunks = chunked(&input, &cuts);
1249            let mut out = VecDeque::new();
1250            TestChunk::advance(&mut chunks, frontier.borrow(), false, &mut out);
1251            TestChunk::advance(&mut chunks, frontier.borrow(), true, &mut out);
1252            prop_assert!(chunks.is_empty());
1253
1254            let expected = consolidate(
1255                input
1256                    .iter()
1257                    .map(|&(d, mut t, r)| {
1258                        t.advance_by(frontier.borrow());
1259                        (d, t, r)
1260                    })
1261                    .collect(),
1262            );
1263            prop_assert_eq!(collect_chunks(out), expected);
1264        }
1265
1266        /// `settle` preserves contents and order, moves everything on `done`,
1267        /// and coalesces small neighbors.
1268        #[mz_ore::test]
1269        #[cfg_attr(miri, ignore)]
1270        fn settle_preserves_and_packs(
1271            input in arb_consolidated(),
1272            cuts in prop::collection::vec(0usize..7, 1..8),
1273        ) {
1274            let mut chunks = chunked(&input, &cuts);
1275            let mut out = VecDeque::new();
1276            TestChunk::settle(&mut chunks, true, &mut out);
1277            prop_assert!(chunks.is_empty());
1278            // Test chunks are far below the byte threshold, so maximal
1279            // packing coalesces everything into a single chunk.
1280            prop_assert!(out.len() <= 1);
1281            prop_assert_eq!(collect_chunks(out), input);
1282        }
1283
1284        /// `ChunkBatch::extract_into` over sorted, deduplicated probe keys
1285        /// equals the reference filter, resident and spilled alike, straddled
1286        /// keys included.
1287        #[mz_ore::test]
1288        #[cfg_attr(miri, ignore)]
1289        fn unload_extract_matches_filter(
1290            input in arb_consolidated(),
1291            cuts in prop::collection::vec(0usize..7, 0..8),
1292            probe_keys in prop::collection::btree_set(0u64..6, 0..6),
1293            spill in any::<bool>(),
1294        ) {
1295            prop_assume!(!input.is_empty());
1296            let pool = test_pool();
1297            let chunks: Vec<TestChunk> = if spill {
1298                chunked_spilled(&input, &cuts, &pool).into()
1299            } else {
1300                chunked(&input, &cuts).into()
1301            };
1302            let description = Description::new(
1303                Antichain::from_elem(0u64),
1304                Antichain::new(),
1305                Antichain::from_elem(0u64),
1306            );
1307            let batch = ChunkBatch::new(chunks, description);
1308
1309            let mut probe_col = <u64 as Columnar>::Container::default();
1310            for key in &probe_keys {
1311                probe_col.push(*key);
1312            }
1313            let mut staging = <Tuple as Columnar>::Container::default();
1314            batch.extract_into(probe_col.borrow(), &mut staging);
1315
1316            let expected: Vec<Tuple> = input
1317                .iter()
1318                .copied()
1319                .filter(|((k, _), _, _)| probe_keys.contains(k))
1320                .collect();
1321            prop_assert_eq!(collect_staging(&staging), expected);
1322
1323            // The scan path reproduces the batch exactly, resident and
1324            // spilled alike.
1325            let mut staging = <Tuple as Columnar>::Container::default();
1326            batch.fetch_into(&mut staging);
1327            prop_assert_eq!(collect_staging(&staging), input);
1328        }
1329    }
1330
1331    /// `locate` answers the three-way span comparison for every probe
1332    /// placement: below, within, and past the chunk's keys.
1333    #[mz_ore::test]
1334    fn locate_spans_keys() {
1335        let chunk = ColumnChunk::from_column(build_column(&[
1336            ((2, 0), 0, 1),
1337            ((4, 0), 0, 1),
1338            ((6, 0), 0, 1),
1339        ]));
1340        let mut probe_col = <u64 as Columnar>::Container::default();
1341        for key in [0u64, 2, 3, 6, 9] {
1342            probe_col.push(key);
1343        }
1344        let probes = probe_col.borrow();
1345        use std::cmp::Ordering::*;
1346        let expected = [Less, Equal, Equal, Equal, Greater];
1347        for (index, expected) in expected.iter().enumerate() {
1348            assert_eq!(chunk.locate(probes, index), *expected, "probe {index}");
1349        }
1350    }
1351
1352    /// Collect chunk contents while asserting each chunk's serialized size
1353    /// stays within `bound` bytes.
1354    fn collect_bounded(chunks: impl IntoIterator<Item = TestChunk>, bound: usize) -> Vec<Tuple> {
1355        let mut collected = Vec::new();
1356        for chunk in chunks {
1357            let col = chunk.into_column();
1358            let bytes = col.length_in_bytes();
1359            assert!(bytes <= bound, "chunk of {bytes} bytes exceeds {bound}");
1360            Extend::extend(&mut collected, collect_column(&col));
1361        }
1362        collected
1363    }
1364
1365    /// Advancing a large input cuts the output into several chunks near the
1366    /// ship threshold, and their concatenation is the reference result.
1367    #[mz_ore::test]
1368    #[cfg_attr(miri, ignore)]
1369    fn advance_cuts_large_output() {
1370        let records: Vec<Tuple> = (0..300_000u64).map(|k| ((k, 0), 0, 1)).collect();
1371        let mut input = VecDeque::from([ColumnChunk::from_column(build_column(&records))]);
1372        let frontier = Antichain::from_elem(0u64);
1373        let mut out = VecDeque::new();
1374        TestChunk::advance(&mut input, frontier.borrow(), true, &mut out);
1375        assert!(input.is_empty());
1376        assert!(
1377            out.len() >= 2,
1378            "expected a cut output, got {} chunk(s)",
1379            out.len()
1380        );
1381        assert_eq!(collect_bounded(out, 2 * COMMIT_BYTES), records);
1382    }
1383
1384    /// An input that is entirely one `D` group is withheld whole as the
1385    /// carry unless `done`: none of it is provably complete.
1386    #[mz_ore::test]
1387    #[cfg_attr(miri, ignore)]
1388    fn advance_withholds_giant_group() {
1389        let records: Vec<Tuple> = (0..100u64).map(|t| ((7, 7), t, 1)).collect();
1390        let mut input: VecDeque<TestChunk> = VecDeque::new();
1391        for piece in records.chunks(30) {
1392            input.push_back(ColumnChunk::from_column(build_column(piece)));
1393        }
1394        let frontier = Antichain::from_elem(50u64);
1395        let mut out = VecDeque::new();
1396        TestChunk::advance(&mut input, frontier.borrow(), false, &mut out);
1397        assert!(out.is_empty(), "nothing may ship from a single open group");
1398        assert_eq!(input.len(), 1, "the whole input becomes one carry chunk");
1399        // Sealing the carry advances and consolidates it.
1400        TestChunk::advance(&mut input, frontier.borrow(), true, &mut out);
1401        assert!(input.is_empty());
1402        let advanced = records.iter().map(|&(d, t, r)| (d, t.max(50), r)).collect();
1403        assert_eq!(collect_chunks(out), consolidate(advanced));
1404    }
1405
1406    /// Extracting a large chunk at an intermediate frontier cuts both sides
1407    /// into several chunks and partitions exactly by time.
1408    #[mz_ore::test]
1409    #[cfg_attr(miri, ignore)]
1410    fn extract_cuts_large_output() {
1411        let records: Vec<Tuple> = (0..300_000u64).map(|k| ((k, 0), k % 2, 1)).collect();
1412        let mut input = VecDeque::from([ColumnChunk::from_column(build_column(&records))]);
1413        let frontier = Antichain::from_elem(1u64);
1414        let mut residual = Antichain::new();
1415        let (mut keep, mut ship) = (VecDeque::new(), VecDeque::new());
1416        while !input.is_empty() {
1417            TestChunk::extract(
1418                &mut input,
1419                frontier.borrow(),
1420                &mut residual,
1421                &mut keep,
1422                &mut ship,
1423            );
1424        }
1425        assert!(
1426            keep.len() >= 2,
1427            "expected a cut keep side, got {} chunk(s)",
1428            keep.len()
1429        );
1430        assert!(
1431            ship.len() >= 2,
1432            "expected a cut ship side, got {} chunk(s)",
1433            ship.len()
1434        );
1435        let kept: Vec<Tuple> = records.iter().copied().filter(|r| r.1 >= 1).collect();
1436        let shipped: Vec<Tuple> = records.iter().copied().filter(|r| r.1 < 1).collect();
1437        assert_eq!(collect_bounded(keep, 2 * COMMIT_BYTES), kept);
1438        assert_eq!(collect_bounded(ship, 2 * COMMIT_BYTES), shipped);
1439        assert_eq!(residual, Antichain::from_elem(1));
1440    }
1441
1442    /// `locate` answers from resident metadata on spilled chunks and follows
1443    /// the probe-relative-to-span convention.
1444    #[mz_ore::test]
1445    fn locate_uses_resident_bounds() {
1446        let pool = test_pool();
1447        let data: Vec<Tuple> = vec![((2, 0), 0, 1), ((4, 0), 0, 1)];
1448        let chunk = force_spill(ColumnChunk::from_column(build_column(&data)), &pool);
1449
1450        let mut probe_col = <u64 as Columnar>::Container::default();
1451        for key in [1u64, 3, 5] {
1452            probe_col.push(key);
1453        }
1454        let probes = probe_col.borrow();
1455        assert_eq!(chunk.locate(probes, 0), std::cmp::Ordering::Less);
1456        assert_eq!(chunk.locate(probes, 1), std::cmp::Ordering::Equal);
1457        assert_eq!(chunk.locate(probes, 2), std::cmp::Ordering::Greater);
1458    }
1459
1460    /// A body large enough to spill round-trips through the pool with resident
1461    /// metadata intact, and the batcher produces spilled sealed output.
1462    #[mz_ore::test]
1463    #[cfg_attr(miri, ignore)] // too slow
1464    fn spill_round_trip() {
1465        set_spill_override(Some(test_pool()));
1466
1467        let data: Vec<Tuple> = (0..40_000u64)
1468            .map(|i| ((i / 4, i % 4), i % 8, 1i64))
1469            .collect();
1470        let data = consolidate(data);
1471
1472        let column = build_column(&data);
1473        let committed = TestChunk::commit(column, 0);
1474        assert!(committed.is_spilled(), "large body must spill");
1475        assert_eq!(committed.len(), data.len());
1476        assert_eq!(collect_column(&committed.clone().into_column()), data);
1477
1478        let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1479        for piece in data.chunks(10_000) {
1480            batcher.push_into(ColumnChunk::from_column(build_column(piece)));
1481        }
1482        let (sealed, _) = batcher.seal(Antichain::new());
1483        assert!(
1484            sealed.iter().any(ColumnChunk::is_spilled),
1485            "sealed output should contain spilled chunks",
1486        );
1487        assert_eq!(collect_chunks(sealed), data);
1488
1489        set_spill_override(None);
1490    }
1491
1492    /// Merging spilled chains loads bodies call-scoped and consolidates
1493    /// correctly, and an untouched survivor keeps its spilled body.
1494    #[mz_ore::test]
1495    #[cfg_attr(miri, ignore)] // too slow
1496    fn merge_spilled_chains() {
1497        set_spill_override(Some(test_pool()));
1498
1499        let a: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 1i64)).collect();
1500        let b: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 2i64)).collect();
1501
1502        let mut in1 = VecDeque::from([TestChunk::commit(build_column(&a), 0)]);
1503        let mut in2 = VecDeque::from([TestChunk::commit(build_column(&b), 0)]);
1504        assert!(in1[0].is_spilled() && in2[0].is_spilled());
1505
1506        let mut out = VecDeque::new();
1507        while !in1.is_empty() && !in2.is_empty() {
1508            TestChunk::merge(&mut in1, &mut in2, &mut out);
1509        }
1510        for tail in in1.drain(..).chain(in2.drain(..)) {
1511            out.push_back(tail);
1512        }
1513
1514        let expected: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 3i64)).collect();
1515        assert_eq!(collect_chunks(out), expected);
1516
1517        set_spill_override(None);
1518    }
1519
1520    /// A merge whose fronts have disjoint key ranges pushes the untouched
1521    /// survivor back in its original (spilled) form rather than rewriting it.
1522    #[mz_ore::test]
1523    fn merge_untouched_survivor_stays_spilled() {
1524        let pool = test_pool();
1525        let low: Vec<Tuple> = (0..100u64).map(|i| ((i, 0), 0, 1i64)).collect();
1526        let high: Vec<Tuple> = (1000..1100u64).map(|i| ((i, 0), 0, 1i64)).collect();
1527
1528        let mut in1 = VecDeque::from([force_spill(
1529            ColumnChunk::from_column(build_column(&low)),
1530            &pool,
1531        )]);
1532        let mut in2 = VecDeque::from([force_spill(
1533            ColumnChunk::from_column(build_column(&high)),
1534            &pool,
1535        )]);
1536        let mut out = VecDeque::new();
1537        TestChunk::merge(&mut in1, &mut in2, &mut out);
1538
1539        // `low` is fully consumed. `high` was never touched and must come
1540        // back spilled.
1541        assert!(in1.is_empty());
1542        assert_eq!(in2.len(), 1);
1543        assert!(in2[0].is_spilled(), "untouched survivor must stay spilled");
1544        let mut all = collect_chunks(out);
1545        Extend::extend(&mut all, collect_chunks(in2.drain(..)));
1546        let mut expected = low;
1547        Extend::extend(&mut expected, high);
1548        assert_eq!(all, expected);
1549    }
1550
1551    /// Merge output is one generation past its deepest input, a survivor
1552    /// rewritten from its remainder keeps its own depth, and a chunk passed
1553    /// through the disjoint fast path keeps its depth.
1554    #[mz_ore::test]
1555    fn merge_derives_generational_depth() {
1556        let low: Vec<Tuple> = (0..100u64).map(|i| ((i, 0), 0, 1i64)).collect();
1557        let high: Vec<Tuple> = (50..150u64).map(|i| ((i, 0), 0, 1i64)).collect();
1558        let mut in1 = VecDeque::from([ColumnChunk::from_column(build_column(&low))]);
1559        let mut in2 = VecDeque::from([ColumnChunk::from_column(build_column(&high))]);
1560        assert_eq!(in1[0].depth(), 0, "fresh chunks start at depth 0");
1561        let mut out = VecDeque::new();
1562        TestChunk::merge(&mut in1, &mut in2, &mut out);
1563        assert!(!out.is_empty());
1564        for chunk in &out {
1565            assert_eq!(chunk.depth(), 1, "merge output is one past its inputs");
1566        }
1567        // The merge runs through the shared horizon, so `high` survives with
1568        // its unmerged remainder at its original depth.
1569        assert!(in1.is_empty());
1570        assert_eq!(in2.len(), 1);
1571        assert_eq!(in2[0].depth(), 0, "rewritten survivor keeps its depth");
1572
1573        // A disjoint merge moves the lower front to the output unchanged.
1574        let mut in1 = VecDeque::from([ColumnChunk::Resident(Rc::new(build_column(&low)), 3)]);
1575        let far: Vec<Tuple> = (1000..1100u64).map(|i| ((i, 0), 0, 1i64)).collect();
1576        let mut in2 = VecDeque::from([ColumnChunk::from_column(build_column(&far))]);
1577        let mut out = VecDeque::new();
1578        TestChunk::merge(&mut in1, &mut in2, &mut out);
1579        assert_eq!(out.len(), 1);
1580        assert_eq!(out[0].depth(), 3, "pass-through keeps its depth");
1581    }
1582
1583    /// Advance output and carry keep the deepest input depth, since
1584    /// compaction rewrites within a generation.
1585    #[mz_ore::test]
1586    fn advance_preserves_depth() {
1587        let data: Vec<Tuple> = (0..100u64).map(|i| ((i, 0), 1, 1i64)).collect();
1588        let mut input = VecDeque::from([
1589            ColumnChunk::Resident(Rc::new(build_column(&data[..50])), 2),
1590            ColumnChunk::Resident(Rc::new(build_column(&data[50..])), 1),
1591        ]);
1592        let frontier = Antichain::from_elem(5u64);
1593        let mut out = VecDeque::new();
1594        TestChunk::advance(&mut input, frontier.borrow(), false, &mut out);
1595        for chunk in out.iter().chain(input.iter()) {
1596            assert_eq!(chunk.depth(), 2);
1597        }
1598        TestChunk::advance(&mut input, frontier.borrow(), true, &mut out);
1599        assert!(input.is_empty());
1600        assert!(!out.is_empty());
1601        for chunk in &out {
1602            assert_eq!(chunk.depth(), 2);
1603        }
1604    }
1605
1606    /// Settle commits at the deepest depth among coalesced chunks, and a
1607    /// commit large enough to spill carries the depth into its spilled
1608    /// metadata (and thus into the pool hints).
1609    #[mz_ore::test]
1610    #[cfg_attr(miri, ignore)] // too slow
1611    fn settle_commits_at_accumulated_depth() {
1612        set_spill_override(Some(test_pool()));
1613        let big: Vec<Tuple> = (0..100_000u64).map(|i| ((i, 0), 0, 1i64)).collect();
1614        let mut input = VecDeque::from([
1615            ColumnChunk::Resident(Rc::new(build_column(&big)), 1),
1616            ColumnChunk::Resident(Rc::new(build_column(&[((0, 0), 0, 1)])), 0),
1617            ColumnChunk::Resident(Rc::new(build_column(&[((1, 0), 0, 1)])), 2),
1618        ]);
1619        let mut out = VecDeque::new();
1620        TestChunk::settle(&mut input, true, &mut out);
1621        assert!(input.is_empty());
1622        assert_eq!(out.len(), 2);
1623        assert!(out[0].is_spilled(), "large commit must spill");
1624        assert_eq!(out[0].depth(), 1, "sole commit keeps its depth");
1625        assert!(!out[1].is_spilled(), "small commit stays resident");
1626        assert_eq!(out[1].depth(), 2, "coalesced commit takes the max depth");
1627        set_spill_override(None);
1628    }
1629
1630    /// The settle carry commits at a monotone size threshold rather than the
1631    /// periodic ship window, so mid-window chunk sizes cannot make it grow
1632    /// past the target unbounded.
1633    #[mz_ore::test]
1634    #[cfg_attr(miri, ignore)] // too slow
1635    fn settle_carry_commits_at_target() {
1636        // ~1.5 MiB per chunk (a row serializes to 32 bytes): under
1637        // `at_commit_size`, so the carry has to coalesce, and a coalesced
1638        // pair lands in the dead zone of the periodic window check.
1639        let chunk_rows = u64::cast_from(1_500_000usize / 32);
1640        let mut input: VecDeque<TestChunk> = (0..4u64)
1641            .map(|c| {
1642                let data: Vec<Tuple> = (0..chunk_rows)
1643                    .map(|i| ((c * chunk_rows + i, 0), 0, 1i64))
1644                    .collect();
1645                ColumnChunk::from_column(build_column(&data))
1646            })
1647            .collect();
1648        let mut out = VecDeque::new();
1649        TestChunk::settle(&mut input, true, &mut out);
1650        // Catches the fixture drifting above `at_commit_size`, where settle
1651        // commits each chunk as-is and the size cap below holds vacuously.
1652        assert!(out.len() < 4, "nothing coalesced");
1653        for chunk in &out {
1654            let col = chunk.clone().into_column();
1655            assert!(
1656                col.length_in_bytes() < 2 * COMMIT_BYTES,
1657                "settled chunk of {} bytes exceeds twice the commit target",
1658                col.length_in_bytes(),
1659            );
1660        }
1661        assert_eq!(
1662            collect_chunks(out).len(),
1663            usize::try_from(4 * chunk_rows).unwrap(),
1664        );
1665    }
1666
1667    /// A tiny chunk stays resident regardless of the spill gate.
1668    #[mz_ore::test]
1669    fn small_chunks_stay_resident() {
1670        set_spill_override(Some(test_pool()));
1671        let committed = TestChunk::commit(build_column(&[((1, 1), 0, 1)]), 0);
1672        assert!(!committed.is_spilled());
1673        set_spill_override(None);
1674    }
1675
1676    /// The smallest column whose serialized size reaches `SPILL_MIN_BYTES`.
1677    /// One record less sits under the spill floor.
1678    fn column_at_spill_floor() -> (Column<Tuple>, u64) {
1679        let mut col: Column<Tuple> = Column::default();
1680        let mut n = 0u64;
1681        while col.length_in_bytes() < SPILL_MIN_BYTES {
1682            col.push_into(((n, n), 0, 1));
1683            n += 1;
1684        }
1685        (col, n)
1686    }
1687
1688    /// Bodies straddling the spill floor: one record under stays resident,
1689    /// at the floor spills.
1690    #[mz_ore::test]
1691    fn spill_floor_boundary() {
1692        set_spill_override(Some(test_pool()));
1693        let (col, n) = column_at_spill_floor();
1694        let mut under: Column<Tuple> = Column::default();
1695        for m in 0..n - 1 {
1696            under.push_into(((m, m), 0, 1));
1697        }
1698        assert!(under.length_in_bytes() < SPILL_MIN_BYTES);
1699        assert!(!TestChunk::commit(under, 0).is_spilled());
1700        assert!(TestChunk::commit(col, 0).is_spilled());
1701        set_spill_override(None);
1702    }
1703
1704    /// The compute and storage spill gates compose as an OR: either gate
1705    /// routes commits to the installed pool, and each setter writes only its
1706    /// own gate.
1707    #[mz_ore::test]
1708    #[cfg_attr(miri, ignore)]
1709    fn spill_gates_compose_as_or() {
1710        let installed =
1711            crate::pool_config::apply_pool_config(crate::pool_config::PoolPagerConfig {
1712                budget_bytes: 32 << 20,
1713                spill_threads: 1,
1714                eager_backing: false,
1715                rss_target_bytes: 16 << 20,
1716            });
1717        assert!(installed, "pool reservation failed");
1718        // A body at the spill floor, so the gates alone decide.
1719        let (col, _) = column_at_spill_floor();
1720        let commit = |col: &Column<Tuple>| TestChunk::commit(col.clone(), 0).is_spilled();
1721
1722        assert!(!commit(&col), "both gates off");
1723        set_storage_spill_enabled(true);
1724        assert!(commit(&col), "the storage gate alone spills");
1725        set_compute_spill_enabled(false);
1726        assert!(
1727            commit(&col),
1728            "the compute setter must not clobber the storage gate"
1729        );
1730        set_compute_spill_enabled(true);
1731        set_storage_spill_enabled(false);
1732        assert!(commit(&col), "the compute gate alone spills");
1733        set_compute_spill_enabled(false);
1734        assert!(!commit(&col), "both gates off again");
1735    }
1736
1737    /// Re-spilling an already-serialized body exercises the `Column::Align`
1738    /// branch of `spill_column` and round-trips byte-identically.
1739    #[mz_ore::test]
1740    fn spill_align_round_trip() {
1741        let pool = test_pool();
1742        let data: Vec<Tuple> = (0..64u64).map(|k| ((k, k), 0, 1)).collect();
1743        let spilled = force_spill(ColumnChunk::from_column(build_column(&data)), &pool);
1744        let column = spilled.into_column();
1745        let Column::Align(words) = &column else {
1746            panic!("a spilled body reads back as Column::Align");
1747        };
1748        let words = words.clone();
1749        let respilled = force_spill(ColumnChunk::from_column(column), &pool);
1750        let reread = respilled.into_column();
1751        let Column::Align(words2) = &reread else {
1752            panic!("a spilled body reads back as Column::Align");
1753        };
1754        assert_eq!(&words, words2, "byte-identical round trip");
1755        assert_eq!(collect_column(&reread), data);
1756    }
1757
1758    /// Merge depth saturates at `u8::MAX` instead of wrapping.
1759    #[mz_ore::test]
1760    fn merge_depth_saturates() {
1761        let a = ColumnChunk::Resident(
1762            Rc::new(build_column(&[((1, 0), 0, 1), ((3, 0), 0, 1)])),
1763            u8::MAX,
1764        );
1765        let b = ColumnChunk::Resident(
1766            Rc::new(build_column(&[((2, 0), 0, 1), ((4, 0), 0, 1)])),
1767            u8::MAX,
1768        );
1769        let mut in1 = VecDeque::from([a]);
1770        let mut in2 = VecDeque::from([b]);
1771        let mut out = VecDeque::new();
1772        TestChunk::merge(&mut in1, &mut in2, &mut out);
1773        for chunk in out.iter().chain(in1.iter()).chain(in2.iter()) {
1774            assert_eq!(chunk.depth(), u8::MAX, "depth saturates");
1775        }
1776    }
1777
1778    /// `into_column` on a shared resident chunk copies instead of stealing
1779    /// the shared body.
1780    #[mz_ore::test]
1781    fn into_column_copies_shared_resident() {
1782        let data: Vec<Tuple> = vec![((1, 1), 0, 1), ((2, 2), 0, 1)];
1783        let a = ColumnChunk::from_column(build_column(&data));
1784        let b = a.clone();
1785        assert_eq!(collect_column(&a.into_column()), data);
1786        assert_eq!(collect_column(&b.into_column()), data);
1787    }
1788}