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 [`ColumnBody`] 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 [`ColumnBody`] on the heap. Fresh input,
16//!   merge 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 contract that lets the pool evict with no reader accounting
25//! (see [`mz_ore::pool`]).
26//!
27//! Spilling happens in [`Chunk::settle`], the trait's designated commit point:
28//! chunks moved to settled output are handed to the pool while the chunk
29//! type's [`SpillGate`] is open. Grading is by serialized
30//! bytes, the ship size [`Column`] already targets, rather than by the
31//! record-count `TARGET`, since record count does not bound bytes for
32//! variable-width data.
33//!
34//! Chunks whose data is a `(key, val)` pair additionally implement
35//! [`UnloadChunk`], the bulk-read capability: sorted probe keys in, matching
36//! updates appended to caller-owned staging, with `locate` answered from the
37//! resident fence metadata so a probe set faults only the chunk bodies it
38//! actually touches.
39
40use std::borrow::Cow;
41#[cfg(test)]
42use std::cell::Cell;
43use std::cell::RefCell;
44use std::collections::VecDeque;
45use std::marker::PhantomData;
46use std::rc::Rc;
47use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
48
49use columnar::bytes::indexed;
50use columnar::{Borrow, BorrowedOf, Columnar, Container as _, Index, Len, Push as _};
51use differential_dataflow::difference::Semigroup;
52use differential_dataflow::lattice::Lattice;
53use differential_dataflow::trace::chunk::Chunk;
54use mz_ore::cast::CastFrom;
55use mz_ore::pool::{ChunkHandle, ChunkHints, ExtentCodec, IDENTITY_CODEC, Pool};
56use smallvec::SmallVec;
57use timely::Accountable;
58use timely::PartialOrder;
59use timely::container::{ContainerBuilder, PushInto};
60use timely::progress::Timestamp;
61use timely::progress::frontier::{Antichain, AntichainRef};
62
63use crate::columnar::batcher::{ColumnChunker, gallop};
64use crate::columnar::body::{ColumnBody, borrow_words};
65use crate::columnar::unload::UnloadChunk;
66use crate::columnar::{Column, at_serialized_capacity};
67
68pub mod metrics;
69
70/// The gate of [`ComputeSpill`] chunks. See [`set_compute_spill_enabled`].
71static COMPUTE_SPILL_ENABLED: AtomicBool = AtomicBool::new(false);
72
73/// The gate of [`StorageSpill`] chunks. See [`set_storage_spill_enabled`].
74static STORAGE_SPILL_ENABLED: AtomicBool = AtomicBool::new(false);
75
76/// The gate for bodies spilled through [`try_spill_ref`]. See
77/// [`set_sink_spill_enabled`].
78static SINK_SPILL_ENABLED: AtomicBool = AtomicBool::new(false);
79
80thread_local! {
81    /// A thread-scoped pool override, taking precedence over the global
82    /// enable flag and pool. Lets tests and benches spill through a private
83    /// pool without touching process-global state.
84    static SPILL_OVERRIDE: RefCell<Option<Pool>> = const { RefCell::new(None) };
85
86    /// A thread-scoped depth-floor override, taking precedence over the
87    /// global value. Lets tests pin the floor without racing concurrently
88    /// running tests on the process-global state.
89    #[cfg(test)]
90    static COMPRESS_MIN_DEPTH_OVERRIDE: Cell<Option<u8>> = const { Cell::new(None) };
91
92    /// Reusable staging for call-scoped reads of spilled bodies.
93    static READ_SCRATCH: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
94}
95
96/// Which process-wide gate decides whether a [`ColumnChunk`] spills.
97///
98/// Compute and storage dataflows share a process and its worker threads, so
99/// the gate is part of the chunk type: each consumer names the gate of the
100/// subsystem that owns it, and one subsystem's flag cannot spill another's
101/// chunks. Every gate takes effect at the next commit, and only once
102/// `apply_pool_config` has installed the pool. With no pool installed chunks
103/// stay resident whatever the gate says.
104pub trait SpillGate: 'static {
105    /// Whether chunks of this gate spill at commit.
106    fn enabled() -> bool;
107}
108
109/// The gate of compute's chunk batchers. See [`set_compute_spill_enabled`].
110pub enum ComputeSpill {}
111
112impl SpillGate for ComputeSpill {
113    fn enabled() -> bool {
114        COMPUTE_SPILL_ENABLED.load(Ordering::Relaxed)
115    }
116}
117
118/// The gate of storage's upsert chunk batchers. See
119/// [`set_storage_spill_enabled`].
120pub enum StorageSpill {}
121
122impl SpillGate for StorageSpill {
123    fn enabled() -> bool {
124        STORAGE_SPILL_ENABLED.load(Ordering::Relaxed)
125    }
126}
127
128/// Enable or disable spilling of [`ComputeSpill`] chunks. Already-spilled
129/// chunks are unaffected either way.
130pub fn set_compute_spill_enabled(enabled: bool) {
131    COMPUTE_SPILL_ENABLED.store(enabled, Ordering::Relaxed);
132}
133
134/// Enable or disable spilling of [`StorageSpill`] chunks. Already-spilled
135/// chunks are unaffected either way.
136pub fn set_storage_spill_enabled(enabled: bool) {
137    STORAGE_SPILL_ENABLED.store(enabled, Ordering::Relaxed);
138}
139
140/// Enable or disable spilling of bodies offered through [`try_spill_ref`],
141/// which is how compute's MV sink correction buffer spills. Like the
142/// [`SpillGate`]s, it takes effect only once `apply_pool_config` has
143/// installed the pool.
144pub fn set_sink_spill_enabled(enabled: bool) {
145    SINK_SPILL_ENABLED.store(enabled, Ordering::Relaxed);
146}
147
148/// Set or unset the pool through which this thread's chunk spills are
149/// routed, taking precedence over the gates and the process pool. `None`
150/// restores the global resolution.
151pub fn set_spill_override(pool: Option<Pool>) {
152    SPILL_OVERRIDE.with(|cell| *cell.borrow_mut() = pool);
153}
154
155/// The youngest generational depth whose spilled bodies are compressed. See
156/// [`set_compress_min_depth`].
157static COMPRESS_MIN_DEPTH: AtomicU8 = AtomicU8::new(DEFAULT_COMPRESS_MIN_DEPTH);
158
159/// Set the youngest generational depth whose spilled bodies are compressed.
160///
161/// A chunk at depth `d` is rewritten (merged, extracted, advanced) with
162/// frequency proportional to `2^-d` under geometric merging, so compressing
163/// a shallow chunk buys a short stay in the pool at the cost of a guaranteed
164/// near-term codec round-trip: the body is encoded only to be read back and
165/// decoded by the next rewrite. Generations below the floor spill under the
166/// identity codec instead: still budgeted and swap-backed like every extent,
167/// but encode and decode are copies. The floor never exempts a body from the
168/// pool, so it cannot grow unbudgeted resident state.
169///
170/// The floor cannot strand a long-lived body uncompressed: a chunk that a
171/// merge carries forward untouched also ages a generation, and its body is
172/// re-spilled under the compressing codec when it crosses the floor (see
173/// `survive_merge`).
174///
175/// `0` compresses every spilled body. Consulted at every commit, so changes
176/// apply to running dataflows.
177pub fn set_compress_min_depth(depth: u8) {
178    COMPRESS_MIN_DEPTH.store(depth, Ordering::Relaxed);
179}
180
181/// Set or unset a thread-scoped depth-floor override, taking precedence over
182/// [`set_compress_min_depth`]. Tests run concurrently and must not race on
183/// the process-global floor.
184#[cfg(test)]
185pub fn set_compress_min_depth_override(depth: Option<u8>) {
186    COMPRESS_MIN_DEPTH_OVERRIDE.with(|cell| cell.set(depth));
187}
188
189/// The depth floor in effect for this thread's commits.
190fn compress_min_depth() -> u8 {
191    #[cfg(test)]
192    if let Some(depth) = COMPRESS_MIN_DEPTH_OVERRIDE.with(|cell| cell.get()) {
193        return depth;
194    }
195    COMPRESS_MIN_DEPTH.load(Ordering::Relaxed)
196}
197
198/// The codec a body at `depth` stores under, identity below the compression
199/// floor and lz4 at and past it, paired with whether that codec compresses.
200/// One read of the floor, so the pair cannot disagree with itself when the
201/// floor moves under a concurrent commit.
202fn codec_for_depth(depth: u8) -> (&'static dyn ExtentCodec, bool) {
203    if depth < compress_min_depth() {
204        (&IDENTITY_CODEC, false)
205    } else {
206        (&LZ4_CODEC, true)
207    }
208}
209
210/// The pool a body of `len_bytes` spills into while `enabled`, or `None` when
211/// it stays resident.
212///
213/// The one place the spill decision lives: the thread override, the gate, the
214/// installed pool, and the size floor.
215fn spill_target(enabled: bool, len_bytes: usize) -> Option<Pool> {
216    resolve_pool(enabled).filter(|_| len_bytes >= SPILL_MIN_BYTES)
217}
218
219/// The thread's override pool, else the installed pool while `enabled`.
220fn resolve_pool(enabled: bool) -> Option<Pool> {
221    if let Some(pool) = SPILL_OVERRIDE.with(|cell| cell.borrow().clone()) {
222        return Some(pool);
223    }
224    if enabled {
225        crate::pool_config::active_pool()
226    } else {
227        None
228    }
229}
230
231/// Scratch capacity retained across reads, in words. A read larger than this
232/// releases the buffer afterward, so a thread's scratch does not ratchet to
233/// the largest body it ever carried (heap no pool gauge can see).
234const SCRATCH_RETAIN_WORDS: usize = 1 << 18;
235
236/// Run `f` with this thread's read scratch, cleared of any previous use.
237fn with_scratch<Out>(f: impl FnOnce(&mut Vec<u64>) -> Out) -> Out {
238    READ_SCRATCH.with(|cell| {
239        let mut scratch = cell.take();
240        scratch.clear();
241        let out = f(&mut scratch);
242        if scratch.capacity() > SCRATCH_RETAIN_WORDS {
243            scratch.clear();
244            scratch.shrink_to_fit();
245        }
246        cell.replace(scratch);
247        out
248    })
249}
250
251/// The serialized-byte size committed chunks aim for, matching the ship size
252/// of the columnar merge machinery.
253const COMMIT_BYTES: usize = 2 << 20;
254
255/// Bodies smaller than this stay resident: the pool's smallest size class is
256/// 64 KiB, so spilling below it trades no meaningful memory for slot waste.
257///
258/// Sub-floor bodies are invisible to the pool's budget, which is safe only
259/// while they are rare. `settle` coalesces toward `COMMIT_BYTES` before
260/// committing, so in the harness only a final `done` tail commits below the
261/// floor. A caller that commits many small chunks directly accumulates
262/// unbudgeted heap, and no accounting here would catch it.
263const SPILL_MIN_BYTES: usize = 64 << 10;
264
265/// The default compression depth floor: fresh (depth 0) bodies spill
266/// uncompressed.
267///
268/// A fresh chunk is consumed by its first merge with certainty, so
269/// compressing it can never save pool bytes for longer than one merge
270/// cadence and always costs a full encode plus decode. Depth 1 and beyond
271/// have survived a merge and wait geometrically longer for the next, so
272/// their compression amortizes.
273const DEFAULT_COMPRESS_MIN_DEPTH: u8 = 1;
274
275/// Records of a body with `len` records in `bytes` that fit in `space`
276/// bytes at the body's average width, keeping 5% of `COMMIT_BYTES` as a
277/// margin for per-piece framing. Uniform rows cut this way fill their size
278/// class and leave one short remainder that `settle` can coalesce. Uneven
279/// widths can still overflow, so callers check the cut piece.
280fn cut_records(len: usize, bytes: usize, space: usize) -> usize {
281    let space = space.saturating_sub(COMMIT_BYTES / 20);
282    (len.saturating_mul(space) / bytes.max(1)).min(len)
283}
284
285/// Whether a body is big enough to commit on its own.
286fn at_commit_size<C: Columnar>(body: &ColumnBody<C>) -> bool {
287    body.length_in_bytes() >= COMMIT_BYTES - COMMIT_BYTES / 10
288}
289
290/// Narrow a columnar ref to a shorter lifetime, so refs from different
291/// borrows, such as a probe column and a chunk's own columns, can be compared
292/// (the refs are lifetime-invariant).
293#[inline(always)]
294fn rr<'b, 'a: 'b, C: Columnar>(item: columnar::Ref<'a, C>) -> columnar::Ref<'b, C> {
295    columnar::ContainerOf::<C>::reborrow_ref(item)
296}
297
298/// A spilled chunk body: the serialized column in the pool, plus the resident
299/// metadata every [`Chunk`] must answer without fetching. That metadata is
300/// the record count, the first and last data items (the fence entries
301/// [`UnloadChunk::locate`] consults), and the time bounds `extract` consults
302/// to pass frontier-disjoint chunks through without loading them.
303pub struct SpilledBody<D: Columnar, T, G> {
304    /// Number of updates in the body.
305    records: usize,
306    /// The first and last data items, as a two-element container. One
307    /// container rather than two singletons, so the leaf allocations are not
308    /// duplicated per fence.
309    fences: D::Container,
310    /// The minimal times in the body: a lower bound antichain every
311    /// contained time is greater-or-equal to. Folded into `extract`'s
312    /// residual frontier when the chunk is kept whole.
313    time_lower: Antichain<T>,
314    /// The maximal times in the body. Some contained time is
315    /// greater-or-equal to a frontier exactly when some maximal time is,
316    /// which is `extract`'s ship-whole test. A single element for totally
317    /// ordered times, hence the inline capacity.
318    time_upper: SmallVec<[T; 1]>,
319    /// Whether the body was inserted under the compressing codec. The pool
320    /// stores the codec itself and reads decode through it, so this is the
321    /// only handle chunk code has on what a body is stored as, and it is
322    /// what `survive_merge` consults to decide a body wants migrating.
323    /// Deriving that from depth instead would tie it to a single transition
324    /// and miss every path that skips it.
325    compressed: bool,
326    /// The body's serialized size, as it was before the pool's codec saw
327    /// it. Retained because the pool reports no per-chunk figure, and a
328    /// chunk that reported nothing would drop its operator's share of the
329    /// batcher's memory out of the introspection tables.
330    len_bytes: usize,
331    /// The pool chunk holding the serialized column.
332    handle: ChunkHandle,
333    /// Carries the chunk's [`SpillGate`], which the resident variant has no
334    /// field to hold.
335    gate: PhantomData<G>,
336}
337
338/// A sorted, consolidated run of `(D, T, R)` updates, resident or spilled.
339///
340/// Every chunk carries a generational depth counting the merge cadences it
341/// has lived through: fresh chunks are depth 0, a merge output is one
342/// generation past its deepest input (saturating at `u8::MAX`, where
343/// remerged long-lived chunks stay), a chunk a merge carries forward
344/// untouched also gains a generation (see `survive_merge`), and rewrites
345/// within a generation (extract, advance, settle coalescing) preserve
346/// depth.
347///
348/// Depth belongs to the chunk, not to the body: a body outlives the chunks
349/// that share it, and aging must not depend on whether a caller happens to
350/// hold the only reference. At spill time the depth becomes the pool's
351/// [`ChunkHints`], so repeatedly merged (older, colder) data lands in deeper
352/// eviction bands. Hints are fixed at insert, so a chunk aged without a
353/// re-spill keeps the band it spilled into.
354///
355/// `G` is the [`SpillGate`] that decides whether commits spill.
356pub enum ColumnChunk<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> {
357    /// Body on the heap, shared via `Rc`, with its generational depth.
358    Resident(Rc<ColumnBody<(D, T, R)>>, u8),
359    /// Body in the pool, with its generational depth. See [`SpilledBody`].
360    Spilled(Rc<SpilledBody<D, T, G>>, u8),
361}
362
363impl<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> Clone for ColumnChunk<D, T, R, G> {
364    fn clone(&self) -> Self {
365        match self {
366            ColumnChunk::Resident(col, depth) => ColumnChunk::Resident(Rc::clone(col), *depth),
367            ColumnChunk::Spilled(body, depth) => ColumnChunk::Spilled(Rc::clone(body), *depth),
368        }
369    }
370}
371
372impl<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> Default for ColumnChunk<D, T, R, G> {
373    fn default() -> Self {
374        ColumnChunk::Resident(Rc::new(ColumnBody::default()), 0)
375    }
376}
377
378impl<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> Accountable for ColumnChunk<D, T, R, G> {
379    fn record_count(&self) -> i64 {
380        i64::try_from(self.records()).expect("record count fits i64")
381    }
382}
383
384impl<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> ColumnChunk<D, T, R, G> {
385    /// Wrap a sorted, consolidated, non-empty body as a resident chunk of
386    /// the youngest generation.
387    pub fn from_body(body: ColumnBody<(D, T, R)>) -> Self {
388        mz_ore::soft_assert_no_log!(!body.is_empty(), "chunks must be non-empty");
389        ColumnChunk::Resident(Rc::new(body), 0)
390    }
391
392    /// Append pieces of at most `COMMIT_BYTES`, preserving order and
393    /// generational depth. A single update may exceed the bound because it
394    /// cannot be split.
395    fn push_bounded(body: ColumnBody<(D, T, R)>, depth: u8, out: &mut VecDeque<Self>) {
396        let len = body.len();
397        let bytes = body.length_in_bytes();
398        if len <= 1 || bytes <= COMMIT_BYTES {
399            if len > 0 {
400                out.push_back(Self::Resident(Rc::new(body), depth));
401            }
402            return;
403        }
404        Self::push_cuts(
405            &body,
406            cut_records(len, bytes, COMMIT_BYTES).max(1),
407            depth,
408            out,
409        );
410    }
411
412    /// Append `body` in consecutive pieces of `records` records. A piece
413    /// that still exceeds `COMMIT_BYTES`, from uneven widths, is halved until
414    /// it fits or holds one record. Halving bounds the recursion depth by
415    /// log2 of `records`, where cutting again at the piece's own average
416    /// width can shrink a piece holding one wide record by only a few
417    /// percent per level.
418    fn push_cuts(
419        body: &ColumnBody<(D, T, R)>,
420        records: usize,
421        depth: u8,
422        out: &mut VecDeque<Self>,
423    ) {
424        let view = body.borrow();
425        Self::push_range(view, 0..view.len(), records, depth, out);
426    }
427
428    /// [`Self::push_cuts`] over `range` of `view`. An oversized piece is
429    /// dropped before descending, and the halves are cut again from `view`,
430    /// so transient memory is one piece rather than one copy of a wide
431    /// update per level.
432    fn push_range(
433        view: BorrowedOf<'_, (D, T, R)>,
434        range: std::ops::Range<usize>,
435        records: usize,
436        depth: u8,
437        out: &mut VecDeque<Self>,
438    ) {
439        let mut start = range.start;
440        while start < range.end {
441            let end = range.end.min(start + records);
442            let mut part = <(D, T, R) as Columnar>::Container::default();
443            part.extend_from_self(view, start..end);
444            let part = ColumnBody::Typed(part);
445            if end - start > 1 && part.length_in_bytes() > COMMIT_BYTES {
446                drop(part);
447                Self::push_range(view, start..end, (end - start).div_ceil(2), depth, out);
448            } else {
449                out.push_back(Self::Resident(Rc::new(part), depth));
450            }
451            start = end;
452        }
453    }
454
455    /// The body, owned. A spilled body is copied out of the pool within this
456    /// call. A shared resident body is copied in its current form.
457    pub fn into_body(self) -> ColumnBody<(D, T, R)> {
458        match self {
459            ColumnChunk::Resident(col, _) => {
460                Rc::try_unwrap(col).unwrap_or_else(|shared| shared.duplicate())
461            }
462            ColumnChunk::Spilled(body, _) => {
463                let mut words = Vec::new();
464                body.handle.read_into(&mut words);
465                ColumnBody::Words(words)
466            }
467        }
468    }
469
470    /// The body for the duration of `f`: a resident body is borrowed, a
471    /// spilled body is loaded from the pool for the call and dropped after it.
472    pub fn with_body<F, X>(&self, f: F) -> X
473    where
474        F: FnOnce(&ColumnBody<(D, T, R)>) -> X,
475    {
476        match self {
477            ColumnChunk::Resident(col, _) => f(col),
478            ColumnChunk::Spilled(body, _) => {
479                let mut words = Vec::new();
480                body.handle.read_into(&mut words);
481                f(&ColumnBody::Words(words))
482            }
483        }
484    }
485
486    /// True when the body lives in the pool.
487    pub fn is_spilled(&self) -> bool {
488        matches!(self, ColumnChunk::Spilled(_, _))
489    }
490
491    /// The number of updates, from resident state only.
492    fn records(&self) -> usize {
493        match self {
494            ColumnChunk::Resident(col, _) => col.len(),
495            ColumnChunk::Spilled(body, _) => body.records,
496        }
497    }
498
499    /// The generational depth, from resident state only.
500    fn depth(&self) -> u8 {
501        match self {
502            ColumnChunk::Resident(_, depth) | ColumnChunk::Spilled(_, depth) => *depth,
503        }
504    }
505
506    /// The first and last data items, from resident state only.
507    fn data_span(&self) -> (columnar::Ref<'_, D>, columnar::Ref<'_, D>) {
508        match self {
509            ColumnChunk::Resident(col, _) => {
510                let data = col.borrow().0;
511                (data.get(0), data.get(data.len() - 1))
512            }
513            ColumnChunk::Spilled(body, _) => {
514                let fences = body.fences.borrow();
515                (fences.get(0), fences.get(1))
516            }
517        }
518    }
519
520    /// Commit a non-empty body at the given generational depth: spill it to
521    /// the pool when `G` is open and the body is worth a slot, else keep it
522    /// resident.
523    fn commit(body: ColumnBody<(D, T, R)>, depth: u8) -> Self
524    where
525        T: Timestamp,
526    {
527        let len_bytes = body.length_in_bytes();
528        metrics::record(metrics::Stage::Commit, body.len(), len_bytes);
529        mz_ore::soft_assert_no_log!(!body.is_empty(), "chunks must be non-empty");
530        match spill_target(G::enabled(), len_bytes) {
531            Some(pool) => Self::spill_body(body, &pool, depth),
532            None => ColumnChunk::Resident(Rc::new(body), depth),
533        }
534    }
535
536    /// Spill a non-empty body into `pool` unconditionally, capturing the
537    /// resident fence metadata.
538    ///
539    /// Generations below the compression depth floor store under the
540    /// identity codec: rewritten too soon for compression to amortize, they
541    /// stay budgeted and swap-backed while encode and decode reduce to
542    /// copies.
543    fn spill_body(body: ColumnBody<(D, T, R)>, pool: &Pool, depth: u8) -> Self
544    where
545        T: Timestamp,
546    {
547        let (codec, compressed) = codec_for_depth(depth);
548        let len_bytes = body.length_in_bytes();
549        let (time_lower, time_upper) = Self::time_bounds(&body);
550        let view = body.borrow();
551        let records = view.len();
552        let mut fences = D::Container::default();
553        fences.push(view.0.get(0));
554        fences.push(view.0.get(records - 1));
555        let handle = spill_serialized(&body, pool, len_bytes, ChunkHints { depth }, codec);
556        ColumnChunk::Spilled(
557            Rc::new(SpilledBody {
558                records,
559                fences,
560                time_lower,
561                time_upper: time_upper.into(),
562                compressed,
563                len_bytes,
564                handle,
565                gate: PhantomData,
566            }),
567            depth,
568        )
569    }
570
571    /// Age a chunk that a merge carried forward untouched by one generation.
572    /// Depth counts merge cadences lived through, not rewrites, so a
573    /// pass-through survivor ages like a merged chunk; the bump rides on the
574    /// chunk, so it is free whether or not the body is shared.
575    ///
576    /// An identity-coded body at or past the floor wants migrating, so a
577    /// sole owner re-spills it compressed; without the re-spill,
578    /// key-disjoint input would keep its whole spilled backlog
579    /// identity-coded for as long as it lived. The test is the body's stored
580    /// codec against the floor, not a depth transition, so a migration that
581    /// cannot happen now (a shared body, no pool installed, a floor lowered
582    /// long after the spill) is retried at the next survival rather than
583    /// consumed. A shared body is skipped because re-spilling this reference
584    /// cannot change what the other holder stores, and the compaction merger
585    /// that shares bodies rewrites its clones immediately.
586    fn survive_merge(self) -> Self
587    where
588        T: Timestamp,
589    {
590        let depth = self.depth().saturating_add(1);
591        match self {
592            ColumnChunk::Resident(col, _) => ColumnChunk::Resident(col, depth),
593            ColumnChunk::Spilled(body, was) => {
594                let migrate = !body.compressed && depth >= compress_min_depth();
595                if !migrate || Rc::strong_count(&body) > 1 {
596                    return ColumnChunk::Spilled(body, depth);
597                }
598                match resolve_pool(G::enabled()) {
599                    Some(pool) => {
600                        let body = ColumnChunk::Spilled(body, was).into_body();
601                        Self::spill_body(body, &pool, depth)
602                    }
603                    None => ColumnChunk::Spilled(body, depth),
604                }
605            }
606        }
607    }
608
609    /// The chunk's time bounds: borrowed from the stored metadata for
610    /// spilled bodies, computed by a time-column scan for resident ones. The
611    /// scan costs less than the copy it lets `extract` avoid when the chunk
612    /// passes through whole.
613    fn chunk_time_bounds(&self) -> (Cow<'_, Antichain<T>>, Cow<'_, [T]>)
614    where
615        T: Timestamp,
616    {
617        match self {
618            ColumnChunk::Resident(col, _) => {
619                let (lower, upper) = Self::time_bounds(col);
620                (Cow::Owned(lower), Cow::Owned(upper))
621            }
622            ColumnChunk::Spilled(body, _) => (
623                Cow::Borrowed(&body.time_lower),
624                Cow::Borrowed(&body.time_upper[..]),
625            ),
626        }
627    }
628
629    /// The time bounds of a non-empty body: the antichain of minimal times
630    /// (every contained time is greater-or-equal to some element) and the
631    /// set of maximal times (some contained time is greater-or-equal to a
632    /// frontier exactly when some maximal one is).
633    fn time_bounds(body: &ColumnBody<(D, T, R)>) -> (Antichain<T>, Vec<T>)
634    where
635        T: Timestamp,
636    {
637        let (_, times, _) = body.borrow();
638        let mut lower = Antichain::new();
639        let mut upper: Vec<T> = Vec::new();
640        // One owned time reused across the scan, so times with owned
641        // allocations do not allocate per element; the bound sets clone only
642        // the elements they retain.
643        let mut time = T::minimum();
644        for i in 0..times.len() {
645            time.copy_from(rr::<T>(times.get(i)));
646            if !upper.iter().any(|u| PartialOrder::less_equal(&time, u)) {
647                upper.retain(|u| !PartialOrder::less_equal(u, &time));
648                upper.push(time.clone());
649            }
650            lower.insert_ref(&time);
651        }
652        (lower, upper)
653    }
654}
655
656/// The chunk-side [`ExtentCodec`]: a little-endian `u32` body-length prefix
657/// followed by one lz4 block, the framing
658/// `lz4_flex::block::compress_prepend_size` produces. Every chunk consumer
659/// passes [`LZ4_CODEC`] at insert; the pool itself has no codec opinion.
660#[derive(Debug)]
661pub struct Lz4Codec;
662
663/// The [`Lz4Codec`] instance chunk consumers pass to
664/// [`Pool::insert_with`].
665pub static LZ4_CODEC: Lz4Codec = Lz4Codec;
666
667impl ExtentCodec for Lz4Codec {
668    fn encode(&self, body: &[u8], out: &mut Vec<u8>) {
669        let max_out = lz4_flex::block::get_maximum_output_size(body.len());
670        out.resize(4 + max_out, 0);
671        let len = u32::try_from(body.len()).expect("chunk bodies are bounded by the size classes");
672        out[..4].copy_from_slice(&len.to_le_bytes());
673        let compressed = lz4_flex::block::compress_into(body, &mut out[4..])
674            .expect("output sized to the maximum");
675        out.truncate(4 + compressed);
676    }
677
678    fn decode(&self, stored: &[u8], body: &mut [u8]) {
679        let prefix: [u8; 4] = stored[..4].try_into().expect("prefix length");
680        let len = usize::try_from(u32::from_le_bytes(prefix)).expect("length fits usize");
681        assert_eq!(
682            len,
683            body.len(),
684            "destination must match the encoded body length"
685        );
686        let written = lz4_flex::block::decompress_into(&stored[4..], body)
687            .expect("stored bytes hold a valid lz4 block");
688        assert_eq!(written, body.len(), "decoded length mismatch");
689    }
690}
691
692/// Serialize a body into a pool slot, writing its serialized form through a cursor over the
693/// slot memory. A serialized body's encoding is one copy. Sizing is exact, so a short or
694/// overlong write is a contract violation and panics.
695fn spill_serialized<C: Columnar>(
696    body: &ColumnBody<C>,
697    pool: &Pool,
698    len_bytes: usize,
699    hints: ChunkHints,
700    codec: &'static dyn ExtentCodec,
701) -> ChunkHandle {
702    mz_ore::soft_assert_eq_no_log!(len_bytes % 8, 0);
703    pool.insert_with(len_bytes / 8, hints, codec, |dst| {
704        let bytes: &mut [u8] = bytemuck::cast_slice_mut(dst);
705        let mut cursor = std::io::Cursor::new(bytes);
706        body.write_into(&mut cursor)
707            .expect("the slot is sized to the serialized body");
708        assert_eq!(
709            usize::try_from(cursor.position()).expect("usize position"),
710            len_bytes,
711            "serialized body must fill the chunk exactly",
712        );
713    })
714}
715
716/// Spill a serialized copy of `body` into the process pool, leaving `body` untouched, or
717/// `None` when the body stays resident.
718///
719/// A body stays resident when the gate [`set_sink_spill_enabled`] sets is off, when no pool is
720/// installed, or when it is smaller than `SPILL_MIN_BYTES`. `depth` is the body's generational
721/// depth: it selects the stored codec and the pool's eviction band.
722///
723/// For consumers that keep their own chunk representation and so cannot use [`ColumnChunk`];
724/// the two share the spill decision through `spill_target`. The encode writes straight into
725/// the pool slot, so a spilled body costs no intermediate allocation and the caller can
726/// [`ColumnBody::clear`] and keep its allocation. A caller that gets `None` back still owns
727/// the body and must keep it resident itself.
728pub fn try_spill_ref<C: Columnar>(body: &ColumnBody<C>, depth: u8) -> Option<ChunkHandle> {
729    let len_bytes = body.length_in_bytes();
730    let pool = spill_target(SINK_SPILL_ENABLED.load(Ordering::Relaxed), len_bytes)?;
731    let (codec, _) = codec_for_depth(depth);
732    Some(spill_serialized(
733        body,
734        &pool,
735        len_bytes,
736        ChunkHints { depth },
737        codec,
738    ))
739}
740
741impl<D, T, R, G> Chunk for ColumnChunk<D, T, R, G>
742where
743    G: SpillGate,
744    D: Columnar,
745    for<'a> columnar::Ref<'a, D>: Copy + Ord,
746    T: Columnar + Default + Timestamp + Lattice + Ord,
747    for<'a> columnar::Ref<'a, T>: Copy + Ord,
748    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
749{
750    type Time = T;
751
752    /// A nominal record count for the harness's fuel and ladder accounting,
753    /// not a bound. Actual chunk sizing is by serialized bytes: `merge` and
754    /// `extract` cut output at the [`Column`] ship threshold, and `settle`
755    /// grades by `at_commit_size`, so a chunk of narrow records can hold more
756    /// records than this and nothing here consults it.
757    const TARGET: usize = 65536;
758
759    fn len(&self) -> usize {
760        self.records()
761    }
762
763    /// [`ColumnBody::merge_from`] does the work: gallop bulk-copies for disjoint
764    /// runs, semigroup consolidation on equal `(data, time)`, output cut at
765    /// the ship threshold.
766    ///
767    /// Fronts whose data ranges are disjoint never load at all: the resident
768    /// fence entries decide, and the lower front moves to the output whole.
769    fn merge(in1: &mut VecDeque<Self>, in2: &mut VecDeque<Self>, out: &mut VecDeque<Self>) {
770        // Disjoint fast path: when one front lies strictly below the other's
771        // first data item (equal boundary data could still interleave on
772        // time), the merged prefix through the shared horizon is exactly that
773        // front, unchanged.
774        let (a_first, a_last) = in1
775            .front()
776            .expect("caller guarantees non-empty input")
777            .data_span();
778        let (b_first, b_last) = in2
779            .front()
780            .expect("caller guarantees non-empty input")
781            .data_span();
782        let a_low = rr::<D>(a_last) < rr::<D>(b_first);
783        let b_low = rr::<D>(b_last) < rr::<D>(a_first);
784        if a_low {
785            let chunk = in1.pop_front().expect("front observed above");
786            out.push_back(chunk.survive_merge());
787            return;
788        }
789        if b_low {
790            let chunk = in2.pop_front().expect("front observed above");
791            out.push_back(chunk.survive_merge());
792            return;
793        }
794
795        let a = in1.pop_front().expect("caller guarantees non-empty input");
796        let b = in2.pop_front().expect("caller guarantees non-empty input");
797        // Merged output is one generation past its deepest input. A survivor
798        // (untouched or rewritten from its remainder) keeps its own depth.
799        let depths = [a.depth(), b.depth()];
800        let out_depth = depths[0].max(depths[1]).saturating_add(1);
801        let mut spill_a = match &a {
802            ColumnChunk::Spilled(body, _) => Some(Rc::clone(body)),
803            ColumnChunk::Resident(_, _) => None,
804        };
805        let mut spill_b = match &b {
806            ColumnChunk::Spilled(body, _) => Some(Rc::clone(body)),
807            ColumnChunk::Resident(_, _) => None,
808        };
809        let mut cols = [a.into_body(), b.into_body()];
810        let mut positions = [0usize, 0usize];
811        loop {
812            let mut result: ColumnBody<(D, T, R)> = ColumnBody::default();
813            let yielded = result.merge_from(&mut cols, &mut positions);
814            if !result.is_empty() {
815                out.push_back(ColumnChunk::Resident(Rc::new(result), out_depth));
816            }
817            if !yielded {
818                break;
819            }
820        }
821        // The disjoint fast paths above move fronts without reading a row, so
822        // only this path counts as merge work.
823        metrics::record(metrics::Stage::Merge, positions[0] + positions[1], 0);
824        let [col_a, col_b] = &mut cols;
825        // Per input side: the loaded column and the merge's consumed position
826        // within it, the side's pre-merge depth, its original spilled body
827        // when it had one, and the deque a survivor returns to.
828        for (col, pos, depth, spilled, queue) in [
829            (col_a, positions[0], depths[0], &mut spill_a, in1),
830            (col_b, positions[1], depths[1], &mut spill_b, in2),
831        ] {
832            let len = col.len();
833            if pos == 0 && len > 0 {
834                // Untouched survivor: restore it as it was (the loaded copy
835                // is dropped), aged one generation by its survival.
836                let chunk = match spilled.take() {
837                    Some(body) => ColumnChunk::Spilled(body, depth),
838                    None => ColumnChunk::Resident(Rc::new(std::mem::take(col)), depth),
839                };
840                queue.push_front(chunk.survive_merge());
841            } else if pos < len {
842                let view = col.borrow();
843                let mut rest = <(D, T, R) as Columnar>::Container::default();
844                rest.extend_from_self(view, pos..len);
845                queue.push_front(ColumnChunk::Resident(
846                    Rc::new(ColumnBody::Typed(rest)),
847                    depth,
848                ));
849            }
850        }
851    }
852
853    /// Partition one front chunk by `frontier`, folding kept times into
854    /// `residual`. One chunk per call, so the harness settles both sides
855    /// between chunks. Output is cut at the ship threshold.
856    fn extract(
857        input: &mut VecDeque<Self>,
858        frontier: AntichainRef<T>,
859        residual: &mut timely::progress::Antichain<T>,
860        keep: &mut VecDeque<Self>,
861        ship: &mut VecDeque<Self>,
862    ) {
863        let Some(chunk) = input.pop_front() else {
864            return;
865        };
866        // Whole-chunk pass-through from the resident time bounds: a chunk
867        // the frontier is entirely past ships unchanged, one entirely at or
868        // past the frontier keeps unchanged. Spilled bodies pass through
869        // without a load, a re-commit, or any codec work; only chunks the
870        // frontier actually splits are loaded below.
871        let (time_lower, time_upper) = chunk.chunk_time_bounds();
872        if time_upper.iter().all(|t| !frontier.less_equal(t)) {
873            ship.push_back(chunk);
874            return;
875        }
876        if time_lower.elements().iter().all(|m| frontier.less_equal(m)) {
877            // The residual must lower-bound every kept time, which is the
878            // chunk's lower bound antichain by construction.
879            for m in time_lower.elements() {
880                residual.insert_ref(m);
881            }
882            keep.push_back(chunk);
883            return;
884        }
885        // Partitioning rewrites within a generation, so both sides keep the
886        // input chunk's depth.
887        let depth = chunk.depth();
888        let mut col = chunk.into_body();
889        let len = col.len();
890        let mut pos = 0;
891        let mut keep_col: ColumnBody<(D, T, R)> = ColumnBody::default();
892        let mut ship_col: ColumnBody<(D, T, R)> = ColumnBody::default();
893        // TODO: rewrite the underlying `ColumnBody::extract` as two passes, the
894        // time column first to find run boundaries, then bulk per-range
895        // copies of the remaining leaves.
896        // Move a side's accumulation to its queue, at the ship threshold
897        // mid-loop, or any non-empty remainder at the end.
898        let cut = |col: &mut ColumnBody<(D, T, R)>, queue: &mut VecDeque<Self>, force: bool| {
899            if !col.is_empty() && (force || at_serialized_capacity(&col.borrow())) {
900                queue.push_back(ColumnChunk::Resident(Rc::new(std::mem::take(col)), depth));
901            }
902        };
903        while pos < len {
904            col.extract(&mut pos, frontier, residual, &mut keep_col, &mut ship_col);
905            if pos < len {
906                cut(&mut keep_col, keep, false);
907                cut(&mut ship_col, ship, false);
908            }
909        }
910        cut(&mut keep_col, keep, true);
911        cut(&mut ship_col, ship, true);
912    }
913
914    /// Advance times by `frontier` and consolidate, withholding the trailing
915    /// `D` group as the carry unless `done` (its updates may continue in input
916    /// this call has not seen).
917    ///
918    /// The input concatenates into the carry's container, so a group that
919    /// grows across many calls is appended to, not rebuilt. Each record is
920    /// copied once on arrival, keeping the run linear. Advancing is
921    /// lattice-monotone but not order-monotone, so each group's advanced
922    /// times are re-sorted before adjacent equal times fold.
923    fn advance(
924        input: &mut VecDeque<Self>,
925        frontier: AntichainRef<T>,
926        done: bool,
927        out: &mut VecDeque<Self>,
928    ) {
929        let Some(front) = input.pop_front() else {
930            return;
931        };
932        // Advancing rewrites within a generation, so output and carry keep
933        // the deepest input depth. Only merges increment.
934        let mut depth = front.depth();
935        // Concatenate the input into one body, reusing the front chunk's
936        // storage when it is exclusively owned (the usual case: it is last
937        // call's carry).
938        let mut base = front.into_body();
939        {
940            let base_c = base.typed_mut();
941            for chunk in input.drain(..) {
942                depth = depth.max(chunk.depth());
943                let col = chunk.into_body();
944                let view = col.borrow();
945                base_c.extend_from_self(view, 0..view.len());
946            }
947        }
948        let view = base.borrow();
949        let total = view.len();
950        if total == 0 {
951            return;
952        }
953        let data = view.0;
954
955        // Giant-group early-out: if the whole input is one `D` group, nothing
956        // is provably complete. Unless `done`, push it all back as the carry.
957        if !done && data.get(0) == data.get(total - 1) {
958            input.push_front(ColumnChunk::Resident(Rc::new(base), depth));
959            return;
960        }
961
962        // The processing bound: everything, or everything before the trailing
963        // `D` group when it must be withheld.
964        let end = if done {
965            total
966        } else {
967            let last = data.get(total - 1);
968            let mut end = total - 1;
969            while end > 0 && data.get(end - 1) == last {
970                end -= 1;
971            }
972            end
973        };
974        // Count only the rows this call advances. The withheld carry is
975        // counted by the call that finally processes it, so a group spanning
976        // many calls counts once.
977        metrics::record(metrics::Stage::Advance, end, 0);
978
979        let mut result = <(D, T, R) as Columnar>::Container::default();
980        // Per-group scratch: advanced owned times with owned diffs.
981        let mut scratch: Vec<(T, R)> = Vec::new();
982        let mut index = 0;
983        // Cut output at the commit size, checked amortized by emitted records
984        // (the size test walks the container's leaves, so probing it per
985        // record would be quadratic). Records, not groups: a single group may
986        // carry arbitrarily many advanced times, and a cut is legal anywhere
987        // in the sorted sequence, so bounding by records keeps the largest
988        // possible output chunk within one check period of the target. It
989        // must not outgrow the pool's largest size class, past which a body
990        // degrades to a permanently resident heap chunk.
991        const CUT_CHECK_RECORDS: usize = 1024;
992        let mut records_since_check = 0usize;
993        // TODO: the output leaves are addressed independently, so a group
994        // that folds nothing (no time collisions, no zeroed diffs) could bulk
995        // `extend_from_self` the D leaf over the whole group range and push
996        // only the advanced times and diffs per record, and a singleton group
997        // (the common case for mostly-unique D) could skip the scratch and
998        // sort round trip entirely.
999        while index < end {
1000            let group_d = data.get(index);
1001            scratch.clear();
1002            while index < end && data.get(index) == group_d {
1003                let (_, t, r) = view.get(index);
1004                let mut owned_t = T::into_owned(t);
1005                owned_t.advance_by(frontier);
1006                scratch.push((owned_t, R::into_owned(r)));
1007                index += 1;
1008            }
1009            scratch.sort_by(|a, b| a.0.cmp(&b.0));
1010            let mut run = scratch.drain(..).peekable();
1011            while let Some((t, mut r)) = run.next() {
1012                while run.peek().is_some_and(|(t2, _)| *t2 == t) {
1013                    let (_, r2) = run.next().expect("peeked");
1014                    r.plus_equals(&r2);
1015                }
1016                if !r.is_zero() {
1017                    result.0.push(group_d);
1018                    result.1.push(&t);
1019                    result.2.push(&r);
1020                    records_since_check += 1;
1021                    if records_since_check >= CUT_CHECK_RECORDS {
1022                        records_since_check = 0;
1023                        if u64::cast_from(indexed::length_in_words(&result.borrow()))
1024                            >= u64::cast_from(COMMIT_BYTES / 8)
1025                        {
1026                            out.push_back(ColumnChunk::Resident(
1027                                Rc::new(ColumnBody::Typed(std::mem::take(&mut result))),
1028                                depth,
1029                            ));
1030                        }
1031                    }
1032                }
1033            }
1034        }
1035        if !result.is_empty() {
1036            out.push_back(ColumnChunk::Resident(
1037                Rc::new(ColumnBody::Typed(result)),
1038                depth,
1039            ));
1040        }
1041
1042        // Rebuild the withheld trailing group as the carry.
1043        if end < total {
1044            let mut carry = <(D, T, R) as Columnar>::Container::default();
1045            carry.extend_from_self(view, end..total);
1046            input.push_front(ColumnChunk::Resident(
1047                Rc::new(ColumnBody::Typed(carry)),
1048                depth,
1049            ));
1050        }
1051    }
1052
1053    /// Grade by serialized bytes and commit: spilled chunks pass through
1054    /// untouched, resident chunks at the commit size commit as they are, and
1055    /// smaller neighbors coalesce until the accumulation reaches it.
1056    /// Committing is the spill hook (see `ColumnChunk::commit`). Unless
1057    /// `done`, a tail below the commit size returns to the front of `input`.
1058    fn settle(input: &mut VecDeque<Self>, done: bool, out: &mut VecDeque<Self>) {
1059        Self::settle_graded(input, done, out, true)
1060    }
1061}
1062
1063impl<D, T, R, G> ColumnChunk<D, T, R, G>
1064where
1065    G: SpillGate,
1066    D: Columnar,
1067    T: Columnar + Timestamp,
1068    R: Columnar,
1069    for<'a> columnar::Ref<'a, D>: Ord,
1070    for<'a> columnar::Ref<'a, T>: Ord,
1071{
1072    /// [`Chunk::settle`], with `commit` choosing what a graded chunk becomes.
1073    ///
1074    /// A caller that reads its output back immediately passes `false`: the
1075    /// chunk is graded resident, so the body is not serialized into a pool
1076    /// slot only to be copied straight back out, and the pool's budget is
1077    /// not charged for a body whose lifetime ends at the next read.
1078    fn settle_graded(
1079        input: &mut VecDeque<Self>,
1080        done: bool,
1081        out: &mut VecDeque<Self>,
1082        commit: bool,
1083    ) {
1084        // Resident chunks below the commit size accumulate into `acc` until
1085        // it reaches that size. Its depth is the deepest among the chunks it
1086        // holds, since coalescing rewrites within a generation.
1087        let mut acc: Option<(ColumnBody<(D, T, R)>, u8)> = None;
1088        while let Some(chunk) = input.pop_front() {
1089            let (rc, depth) = match chunk {
1090                spilled @ ColumnChunk::Spilled(_, _) => {
1091                    if let Some((body, depth)) = acc.take() {
1092                        out.push_back(Self::grade(body, depth, commit));
1093                    }
1094                    out.push_back(spilled);
1095                    continue;
1096                }
1097                ColumnChunk::Resident(rc, depth) => (rc, depth),
1098            };
1099            if rc.length_in_bytes() > COMMIT_BYTES && rc.len() > 1 {
1100                // Cut by borrow, so a shared body is copied once, into its
1101                // pieces.
1102                let records = cut_records(rc.len(), rc.length_in_bytes(), COMMIT_BYTES).max(1);
1103                let mut pieces = VecDeque::new();
1104                Self::push_cuts(&rc, records, depth, &mut pieces);
1105                for piece in pieces.into_iter().rev() {
1106                    input.push_front(piece);
1107                }
1108                continue;
1109            }
1110            let full = at_commit_size(&rc);
1111            // A chunk is copied into `acc` from its borrow, so a shared body
1112            // is never unwrapped.
1113            let fits = acc.as_ref().is_some_and(|(body, _)| {
1114                body.length_in_bytes().saturating_add(rc.length_in_bytes()) <= COMMIT_BYTES
1115            });
1116            if !full
1117                && fits
1118                && let Some((mut body, acc_depth)) = acc.take()
1119            {
1120                let view = rc.borrow();
1121                body.typed_mut().extend_from_self(view, 0..view.len());
1122                let acc_depth = acc_depth.max(depth);
1123                if at_commit_size(&body) {
1124                    out.push_back(Self::grade(body, acc_depth, commit));
1125                } else {
1126                    acc = Some((body, acc_depth));
1127                }
1128                continue;
1129            }
1130            // The chunk does not fit in `acc`. If the chunk is below the
1131            // commit size, as many of its records as fit move into `acc` and
1132            // the rest return to the front of `input`. Without that move, a
1133            // stream of chunks just over half the bound would commit each one
1134            // at about half the bound. Either way `acc` then goes to `out`.
1135            if let Some((mut body, acc_depth)) = acc.take() {
1136                let len = rc.len();
1137                let prefix = if full {
1138                    0
1139                } else {
1140                    let space = COMMIT_BYTES.saturating_sub(body.length_in_bytes());
1141                    cut_records(len, rc.length_in_bytes(), space)
1142                };
1143                if prefix == 0 || prefix >= len {
1144                    out.push_back(Self::grade(body, acc_depth, commit));
1145                } else {
1146                    let view = rc.borrow();
1147                    body.typed_mut().extend_from_self(view, 0..prefix);
1148                    let mut rest = <(D, T, R) as Columnar>::Container::default();
1149                    rest.extend_from_self(view, prefix..len);
1150                    input.push_front(ColumnChunk::Resident(
1151                        Rc::new(ColumnBody::Typed(rest)),
1152                        depth,
1153                    ));
1154                    let acc_depth = acc_depth.max(depth);
1155                    if body.length_in_bytes() > COMMIT_BYTES {
1156                        // Uneven record widths pushed `acc` past the bound.
1157                        let mut pieces = VecDeque::new();
1158                        Self::push_bounded(body, acc_depth, &mut pieces);
1159                        for piece in pieces {
1160                            let ColumnChunk::Resident(piece, piece_depth) = piece else {
1161                                unreachable!("push_bounded emits resident pieces");
1162                            };
1163                            let piece =
1164                                Rc::try_unwrap(piece).unwrap_or_else(|piece| piece.duplicate());
1165                            out.push_back(Self::grade(piece, piece_depth, commit));
1166                        }
1167                    } else {
1168                        out.push_back(Self::grade(body, acc_depth, commit));
1169                    }
1170                    continue;
1171                }
1172            }
1173            let body = Rc::try_unwrap(rc).unwrap_or_else(|rc| rc.duplicate());
1174            if full {
1175                out.push_back(Self::grade(body, depth, commit));
1176            } else {
1177                acc = Some((body, depth));
1178            }
1179        }
1180        if let Some((body, depth)) = acc {
1181            if done {
1182                out.push_back(Self::grade(body, depth, commit));
1183            } else {
1184                input.push_front(ColumnChunk::Resident(Rc::new(body), depth));
1185            }
1186        }
1187    }
1188
1189    /// A graded body as a chunk: committed, which may spill it, or left
1190    /// resident.
1191    fn grade(body: ColumnBody<(D, T, R)>, depth: u8, commit: bool) -> Self {
1192        if commit {
1193            Self::commit(body, depth)
1194        } else {
1195            ColumnChunk::Resident(Rc::new(body), depth)
1196        }
1197    }
1198}
1199
1200/// Append every update in `view` whose key matches a probe at or after
1201/// `*probe_index` into `staging`, per the [`UnloadChunk`] consume-index
1202/// protocol: probes strictly below the view's last key are consumed, a probe
1203/// equal to it is extracted but left for the next chunk.
1204fn extract_view_into<'v, 'p, K, V, T, R>(
1205    view: BorrowedOf<'v, ((K, V), T, R)>,
1206    probes: BorrowedOf<'p, K>,
1207    probe_index: &mut usize,
1208    staging: &mut <((K, V), T, R) as Columnar>::Container,
1209) where
1210    K: Columnar,
1211    V: Columnar,
1212    T: Columnar,
1213    R: Columnar,
1214    for<'b> columnar::Ref<'b, K>: Copy + Ord,
1215{
1216    let keys = view.0.0;
1217    let len = keys.len();
1218    let last = keys.get(len - 1);
1219    let count = probes.len();
1220    let mut pos = 0;
1221    while *probe_index < count {
1222        let probe = probes.get(*probe_index);
1223        mz_ore::soft_assert_no_log!(
1224            *probe_index == 0 || rr::<K>(probes.get(*probe_index - 1)) < rr::<K>(probe),
1225            "probe keys must be sorted and deduplicated"
1226        );
1227        if rr::<K>(probe) > rr::<K>(last) {
1228            return;
1229        }
1230        gallop(len, &mut pos, |i| rr::<K>(keys.get(i)) < rr::<K>(probe));
1231        let start = pos;
1232        while pos < len && rr::<K>(keys.get(pos)) == rr::<K>(probe) {
1233            pos += 1;
1234        }
1235        staging.extend_from_self(view, start..pos);
1236        if rr::<K>(probe) == rr::<K>(last) {
1237            return;
1238        }
1239        *probe_index += 1;
1240    }
1241}
1242
1243impl<K, V, T, R, G> UnloadChunk for ColumnChunk<(K, V), T, R, G>
1244where
1245    G: SpillGate,
1246    K: Columnar,
1247    for<'a> columnar::Ref<'a, K>: Copy + Ord,
1248    V: Columnar,
1249    for<'a> columnar::Ref<'a, V>: Copy + Ord,
1250    T: Columnar + Default + Timestamp + Lattice + Ord,
1251    for<'a> columnar::Ref<'a, T>: Copy + Ord,
1252    R: Columnar + Default + Semigroup + for<'a> Semigroup<columnar::Ref<'a, R>>,
1253{
1254    /// The flat columnar accumulation. Appends are bulk column-range copies,
1255    /// and a group straddling chunks stitches by plain concatenation.
1256    type Staging = <((K, V), T, R) as Columnar>::Container;
1257
1258    /// A borrowed key column, e.g. of a `Column<K>` the consumer assembled
1259    /// from its sorted, deduplicated probe keys.
1260    type Probes<'a> = BorrowedOf<'a, K>;
1261
1262    fn probe_count(probes: Self::Probes<'_>) -> usize {
1263        probes.len()
1264    }
1265
1266    fn locate(&self, probes: Self::Probes<'_>, probe_index: usize) -> std::cmp::Ordering {
1267        let probe = probes.get(probe_index);
1268        // A data ref is a `(key ref, val ref)` tuple, so the key fences are a
1269        // projection of the data fences.
1270        let (first, last) = self.data_span();
1271        let (first, last) = (first.0, last.0);
1272        if rr::<K>(probe) < rr::<K>(first) {
1273            std::cmp::Ordering::Less
1274        } else if rr::<K>(probe) > rr::<K>(last) {
1275            std::cmp::Ordering::Greater
1276        } else {
1277            std::cmp::Ordering::Equal
1278        }
1279    }
1280
1281    fn extract_into(
1282        &self,
1283        probes: Self::Probes<'_>,
1284        probe_index: &mut usize,
1285        staging: &mut Self::Staging,
1286    ) {
1287        match self {
1288            ColumnChunk::Resident(col, _) => {
1289                extract_view_into::<K, V, T, R>(col.borrow(), probes, probe_index, staging);
1290            }
1291            ColumnChunk::Spilled(body, _) => with_scratch(|scratch| {
1292                // NOTE: deliberately the non-admitting read. One probe set
1293                // touching a chunk is weak evidence it will be touched again,
1294                // and probing a spilled trace must not accrete it back into
1295                // residency. The cost is a full decode per probe set against
1296                // an evicted chunk.
1297                body.handle.read_into(scratch);
1298                let view = borrow_words::<((K, V), T, R)>(scratch);
1299                extract_view_into::<K, V, T, R>(view, probes, probe_index, staging);
1300            }),
1301        }
1302    }
1303
1304    fn fetch_into(&self, staging: &mut Self::Staging) {
1305        match self {
1306            ColumnChunk::Resident(col, _) => {
1307                let view = col.borrow();
1308                staging.extend_from_self(view, 0..view.len());
1309            }
1310            ColumnChunk::Spilled(body, _) => with_scratch(|scratch| {
1311                body.handle.read_into(scratch);
1312                let view = borrow_words::<((K, V), T, R)>(scratch);
1313                staging.extend_from_self(view, 0..view.len());
1314            }),
1315        }
1316    }
1317}
1318
1319/// A batch builder over [`ColumnChunk`] input that delegates to a builder
1320/// over [`ColumnBody`] input, loading each chunk's body as it is pushed.
1321///
1322/// This is the adapter that lets a [`ChunkBatcher`] feed the body-input batch
1323/// builders (and through them the existing spine layouts):
1324/// the batcher's chains carry pool-spillable chunks, and bodies are read back
1325/// copy-out only at the seal, one chunk at a time.
1326///
1327/// [`ChunkBatcher`]: differential_dataflow::trace::chunk::ChunkBatcher
1328pub struct UnchunkBuilder<Bu, D: Columnar, T: Columnar, R: Columnar, G: SpillGate> {
1329    inner: Bu,
1330    _marker: std::marker::PhantomData<(D, T, R, G)>,
1331}
1332
1333impl<Bu, D, T, R, G> differential_dataflow::trace::Builder for UnchunkBuilder<Bu, D, T, R, G>
1334where
1335    Bu: differential_dataflow::trace::Builder<Input = ColumnBody<(D, T, R)>> + ChainState,
1336    D: Columnar + 'static,
1337    T: Columnar + 'static,
1338    R: Columnar + 'static,
1339    G: SpillGate,
1340{
1341    type Input = ColumnChunk<D, T, R, G>;
1342    type Time = Bu::Time;
1343    type Output = Bu::Output;
1344
1345    fn with_capacity(keys: usize, vals: usize, upds: usize) -> Self {
1346        Self {
1347            inner: Bu::with_capacity(keys, vals, upds),
1348            _marker: std::marker::PhantomData,
1349        }
1350    }
1351
1352    fn push(&mut self, chunk: &mut Self::Input) {
1353        let mut body = std::mem::take(chunk).into_body();
1354        self.inner.push(&mut body);
1355    }
1356
1357    fn done(
1358        self,
1359        description: differential_dataflow::trace::Description<Self::Time>,
1360    ) -> Self::Output {
1361        self.inner.done(description)
1362    }
1363
1364    fn seal(
1365        chain: &mut Vec<Self::Input>,
1366        description: differential_dataflow::trace::Description<Self::Time>,
1367    ) -> Self::Output {
1368        let mut state = Bu::State::default();
1369        for chunk in chain.iter() {
1370            // A resident body folds for the price of a borrow, so it goes to
1371            // `observe` whatever the builder asked for: the state gets exact
1372            // figures for free. Folding a spilled body costs a second pool
1373            // read on top of the push below, so it waits on `wants_bodies`.
1374            if !chunk.is_spilled() || Bu::wants_bodies() {
1375                chunk.with_body(|body| Bu::observe(&mut state, body));
1376            } else {
1377                Bu::observe_records(&mut state, chunk.records());
1378            }
1379        }
1380        let mut builder = Self {
1381            inner: Bu::from_state(state),
1382            _marker: std::marker::PhantomData,
1383        };
1384        // One chunk at a time through `push`, so peak transient memory is a
1385        // single loaded body rather than the whole chain at once.
1386        for chunk in chain.iter_mut() {
1387            builder.push(chunk);
1388        }
1389        chain.clear();
1390        builder.done(description)
1391    }
1392}
1393
1394/// A builder whose batches carry state derived from a whole chain, computed
1395/// before the chain's first push.
1396///
1397/// [`Builder::seal`] receives the chain at once, which a chain of pool-backed
1398/// chunks cannot supply without holding every body resident at the same time.
1399/// An implementor splits the derivation instead: a caller folds the chain into
1400/// [`State`](Self::State) one entry at a time, then builds from it.
1401///
1402/// A caller folds each chain entry exactly once, through
1403/// [`observe`](Self::observe) when it holds the entry's contents and through
1404/// [`observe_records`](Self::observe_records) when it does not, so an
1405/// implementor may accumulate the same figure in both without double
1406/// counting. Which one an entry takes is the caller's choice, so an
1407/// implementor reads no meaning into it beyond what each carries.
1408///
1409/// [`Builder::seal`]: differential_dataflow::trace::Builder::seal
1410pub trait ChainState: differential_dataflow::trace::Builder {
1411    /// State accumulated across a chain.
1412    type State: Default;
1413
1414    /// Whether the state is worth reading a chain entry's contents for when
1415    /// they are not already at hand. A caller that holds them passes them on
1416    /// regardless.
1417    fn wants_bodies() -> bool;
1418
1419    /// Fold one chain entry's contents into `state`.
1420    fn observe(state: &mut Self::State, input: &Self::Input);
1421
1422    /// Fold one chain entry's update count into `state`, for a caller that
1423    /// reads no bodies.
1424    fn observe_records(state: &mut Self::State, records: usize);
1425
1426    /// Build with `state` installed.
1427    fn from_state(state: Self::State) -> Self;
1428}
1429
1430/// A chunker for `arrange_core` over [`ColumnChunk`]s: sorts and consolidates
1431/// raw input columns through a [`ColumnChunker`] and wraps its output chunks.
1432pub struct ChunkChunker<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> {
1433    inner: ColumnChunker<(D, T, R)>,
1434    ready: VecDeque<ColumnChunk<D, T, R, G>>,
1435    staged: ColumnChunk<D, T, R, G>,
1436}
1437
1438impl<D, T, R, G> Default for ChunkChunker<D, T, R, G>
1439where
1440    D: Columnar,
1441    T: Columnar,
1442    R: Columnar,
1443    G: SpillGate,
1444    ColumnChunker<(D, T, R)>: Default,
1445{
1446    fn default() -> Self {
1447        Self {
1448            inner: Default::default(),
1449            ready: VecDeque::new(),
1450            staged: Default::default(),
1451        }
1452    }
1453}
1454
1455impl<'a, D, T, R, G> PushInto<&'a mut Column<(D, T, R)>> for ChunkChunker<D, T, R, G>
1456where
1457    D: Columnar,
1458    T: Columnar,
1459    R: Columnar,
1460    G: SpillGate,
1461    ColumnChunker<(D, T, R)>: PushInto<&'a mut Column<(D, T, R)>>,
1462{
1463    fn push_into(&mut self, item: &'a mut Column<(D, T, R)>) {
1464        self.inner.push_into(item);
1465    }
1466}
1467
1468impl<D, T, R, G> ContainerBuilder for ChunkChunker<D, T, R, G>
1469where
1470    D: Columnar + 'static,
1471    T: Columnar + 'static,
1472    R: Columnar + 'static,
1473    G: SpillGate,
1474    ColumnChunker<(D, T, R)>: ContainerBuilder<Container = ColumnBody<(D, T, R)>>,
1475{
1476    type Container = ColumnChunk<D, T, R, G>;
1477
1478    fn extract(&mut self) -> Option<&mut Self::Container> {
1479        if self.ready.is_empty() {
1480            let body = self.inner.extract()?;
1481            ColumnChunk::push_bounded(std::mem::take(body), 0, &mut self.ready);
1482        }
1483        self.staged = self.ready.pop_front()?;
1484        Some(&mut self.staged)
1485    }
1486
1487    fn finish(&mut self) -> Option<&mut Self::Container> {
1488        if self.ready.is_empty() {
1489            let body = self.inner.finish()?;
1490            ColumnChunk::push_bounded(std::mem::take(body), 0, &mut self.ready);
1491        }
1492        self.staged = self.ready.pop_front()?;
1493        Some(&mut self.staged)
1494    }
1495}
1496
1497/// The [`ChunkBatcher`] of a chunk chain, reporting resident bytes to the
1498/// batcher size logger.
1499///
1500/// [`ChunkBatcher`]: differential_dataflow::trace::chunk::ChunkBatcher
1501pub type AccountedChunkBatcher<D, T, R, G> =
1502    differential_dataflow::trace::implementations::merge_batcher::MergeBatcher<
1503        AccountedChunkMerger<D, T, R, G>,
1504    >;
1505
1506/// The chunk merger of [`AccountedChunkBatcher`]: differential's merger with
1507/// a resident readied side, plus the [`Merger::allocation`] figures the
1508/// `mz_arrangement_batcher_*_raw` introspection tables are assembled from.
1509///
1510/// A chunk reports the bytes of the body it holds whether that body sits on
1511/// the heap or in the pool. A spilled body is not free: the pool keeps it in
1512/// a slot, or compressed in an extent, or on the device, and only the last
1513/// of those is off the process's books. Which tier holds it is the pool's
1514/// own business and its metrics report that split, so attributing the body
1515/// to the operator that owns it is what keeps these tables whole.
1516///
1517/// [`Merger::allocation`]: differential_dataflow::trace::implementations::merge_batcher::Merger::allocation
1518pub struct AccountedChunkMerger<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> {
1519    inner: differential_dataflow::trace::chunk::ChunkMerger<ColumnChunk<D, T, R, G>>,
1520}
1521
1522impl<D: Columnar, T: Columnar, R: Columnar, G: SpillGate> Default
1523    for AccountedChunkMerger<D, T, R, G>
1524{
1525    fn default() -> Self {
1526        Self {
1527            inner: Default::default(),
1528        }
1529    }
1530}
1531
1532impl<D, T, R, G> differential_dataflow::trace::implementations::merge_batcher::Merger
1533    for AccountedChunkMerger<D, T, R, G>
1534where
1535    D: Columnar + 'static,
1536    T: Columnar + Timestamp + 'static,
1537    R: Columnar + 'static,
1538    G: SpillGate,
1539    for<'a> columnar::Ref<'a, D>: Ord,
1540    for<'a> columnar::Ref<'a, T>: Ord,
1541    ColumnChunk<D, T, R, G>: Chunk,
1542    <ColumnChunk<D, T, R, G> as Chunk>::Time: Clone + PartialOrder + 'static,
1543{
1544    type Chunk = ColumnChunk<D, T, R, G>;
1545    type Time = <ColumnChunk<D, T, R, G> as Chunk>::Time;
1546
1547    fn merge(
1548        &mut self,
1549        list1: Vec<Self::Chunk>,
1550        list2: Vec<Self::Chunk>,
1551        output: &mut Vec<Self::Chunk>,
1552        stash: &mut Vec<Self::Chunk>,
1553    ) {
1554        self.inner.merge(list1, list2, output, stash)
1555    }
1556
1557    fn extract(
1558        &mut self,
1559        merged: Vec<Self::Chunk>,
1560        upper: AntichainRef<Self::Time>,
1561        frontier: &mut Antichain<Self::Time>,
1562        readied: &mut Vec<Self::Chunk>,
1563        kept: &mut Vec<Self::Chunk>,
1564        _stash: &mut Vec<Self::Chunk>,
1565    ) {
1566        // Differential's merger settles both sides with committing settles.
1567        // The readied side is handed to a builder that loads every chunk
1568        // back, so committing it spills bodies the next call copies out
1569        // again, and charges the pool's budget for them in between. Drive
1570        // the loop here instead and grade that side resident. The kept side
1571        // stays committed: it is the side that lives on, and spilling it is
1572        // the point.
1573        let mut input: VecDeque<Self::Chunk> = merged.into();
1574        let (mut keep, mut shipped) = (VecDeque::new(), VecDeque::new());
1575        let (mut kept_q, mut shipped_q) = (VecDeque::new(), VecDeque::new());
1576        while !input.is_empty() {
1577            Chunk::extract(&mut input, upper, frontier, &mut keep, &mut shipped);
1578            Chunk::settle(&mut keep, false, &mut kept_q);
1579            ColumnChunk::settle_graded(&mut shipped, false, &mut shipped_q, false);
1580        }
1581        Chunk::settle(&mut keep, true, &mut kept_q);
1582        ColumnChunk::settle_graded(&mut shipped, true, &mut shipped_q, false);
1583        Extend::extend(kept, kept_q);
1584        Extend::extend(readied, shipped_q);
1585    }
1586
1587    fn len(chunk: &Self::Chunk) -> usize {
1588        Chunk::len(chunk)
1589    }
1590
1591    fn allocation(chunk: &Self::Chunk) -> (usize, usize, usize) {
1592        let bytes = match chunk {
1593            ColumnChunk::Resident(col, _) => col.length_in_bytes(),
1594            ColumnChunk::Spilled(body, _) => body.len_bytes,
1595        };
1596        (bytes, bytes, 1)
1597    }
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    //! Property tests for the [`Chunk`] and [`UnloadChunk`] contracts on
1603    //! [`ColumnChunk`].
1604    //!
1605    //! Strategy: generate sorted+consolidated inputs (the chunk invariant),
1606    //! drive the trait methods the way the differential harness does, and
1607    //! compare against brute-force references on owned tuples. Test types are
1608    //! `D = (u64, u64)`, `T = u64`, `R = i64` from small ranges so equal-key
1609    //! collisions are common and consolidation actually runs.
1610
1611    use differential_dataflow::trace::chunk::{ChunkBatch, ChunkBatcher};
1612    use differential_dataflow::trace::{Batcher, Description};
1613    use mz_ore::pool::Pool;
1614    use proptest::prelude::*;
1615    use timely::container::PushInto;
1616    use timely::progress::Antichain;
1617
1618    use crate::columnar::unload::UnloadBatch;
1619
1620    use super::*;
1621
1622    type Tuple = ((u64, u64), u64, i64);
1623    type TestChunk = ColumnChunk<(u64, u64), u64, i64, ComputeSpill>;
1624
1625    /// The delegated codec's stored form is byte-identical to the extent
1626    /// store's previous hard-coded framing: a little-endian `u32`
1627    /// body-length prefix followed by one lz4 block, which is exactly what
1628    /// `compress_prepend_size` produces.
1629    #[mz_ore::test]
1630    fn lz4_codec_matches_the_previous_extent_framing() {
1631        let body: Vec<u8> = (0..100_000u32).flat_map(|i| i.to_le_bytes()).collect();
1632        let mut stored = Vec::new();
1633        LZ4_CODEC.encode(&body, &mut stored);
1634        assert_eq!(stored, lz4_flex::block::compress_prepend_size(&body));
1635        let mut round = vec![0u8; body.len()];
1636        LZ4_CODEC.decode(&stored, &mut round);
1637        assert_eq!(round, body);
1638    }
1639
1640    #[mz_ore::test]
1641    #[should_panic(expected = "destination must match")]
1642    fn lz4_codec_decode_length_mismatch_panics() {
1643        let mut stored = Vec::new();
1644        LZ4_CODEC.encode(&[7u8; 64], &mut stored);
1645        let mut short = vec![0u8; 32];
1646        LZ4_CODEC.decode(&stored, &mut short);
1647    }
1648
1649    /// Reference consolidation: sort by `(data, time)`, sum diffs over equal
1650    /// pairs, drop zeros.
1651    fn consolidate(mut v: Vec<Tuple>) -> Vec<Tuple> {
1652        v.sort();
1653        let mut out: Vec<Tuple> = Vec::new();
1654        for (d, t, r) in v {
1655            if let Some(last) = out.last_mut() {
1656                if last.0 == d && last.1 == t {
1657                    last.2 += r;
1658                    continue;
1659                }
1660            }
1661            out.push((d, t, r));
1662        }
1663        out.retain(|x| x.2 != 0);
1664        out
1665    }
1666
1667    fn arb_consolidated() -> impl Strategy<Value = Vec<Tuple>> {
1668        prop::collection::vec(((0u64..5, 0u64..5), 0u64..4, -3i64..=3i64), 0..40)
1669            .prop_map(consolidate)
1670    }
1671
1672    fn build_column(v: &[Tuple]) -> ColumnBody<Tuple> {
1673        let mut col: ColumnBody<Tuple> = Default::default();
1674        for tup in v {
1675            col.push_into(*tup);
1676        }
1677        col
1678    }
1679
1680    fn collect_column(col: &ColumnBody<Tuple>) -> Vec<Tuple> {
1681        col.borrow()
1682            .into_index_iter()
1683            .map(|((k, v), t, r)| {
1684                (
1685                    (u64::into_owned(k), u64::into_owned(v)),
1686                    u64::into_owned(t),
1687                    i64::into_owned(r),
1688                )
1689            })
1690            .collect()
1691    }
1692
1693    fn collect_chunks(chunks: impl IntoIterator<Item = TestChunk>) -> Vec<Tuple> {
1694        chunks
1695            .into_iter()
1696            .flat_map(|chunk| collect_column(&chunk.into_body()))
1697            .collect()
1698    }
1699
1700    fn collect_staging(staging: &<Tuple as Columnar>::Container) -> Vec<Tuple> {
1701        staging
1702            .borrow()
1703            .into_index_iter()
1704            .map(|((k, v), t, r)| {
1705                (
1706                    (u64::into_owned(k), u64::into_owned(v)),
1707                    u64::into_owned(t),
1708                    i64::into_owned(r),
1709                )
1710            })
1711            .collect()
1712    }
1713
1714    /// Cut consolidated data into non-empty chunks at the given points.
1715    fn chunked(data: &[Tuple], cuts: &[usize]) -> VecDeque<TestChunk> {
1716        let mut chunks = VecDeque::new();
1717        let mut start = 0;
1718        for cut in cuts {
1719            let end = (start + 1 + cut % 7).min(data.len());
1720            if end > start {
1721                chunks.push_back(ColumnChunk::from_body(build_column(&data[start..end])));
1722                start = end;
1723            }
1724        }
1725        if start < data.len() {
1726            chunks.push_back(ColumnChunk::from_body(build_column(&data[start..])));
1727        }
1728        chunks
1729    }
1730
1731    /// The chunked cut, with every chunk force-spilled through a private pool
1732    /// (bounds captured, bodies in the pool) regardless of size thresholds.
1733    fn chunked_spilled(data: &[Tuple], cuts: &[usize], pool: &Pool) -> VecDeque<TestChunk> {
1734        chunked(data, cuts)
1735            .into_iter()
1736            .map(|chunk| force_spill(chunk, pool))
1737            .collect()
1738    }
1739
1740    /// Whether a spilled chunk's body is stored compressed. Deliberately
1741    /// does not retain the body: an `Rc` held across a `survive_merge` would
1742    /// itself make the body shared and suppress the migration under test.
1743    fn body_compressed(chunk: &TestChunk) -> bool {
1744        match chunk {
1745            ColumnChunk::Spilled(body, _) => body.compressed,
1746            ColumnChunk::Resident(_, _) => panic!("chunk must be spilled"),
1747        }
1748    }
1749
1750    /// Spill one chunk through `pool`, bypassing the size threshold and
1751    /// keeping the chunk's depth.
1752    fn force_spill(chunk: TestChunk, pool: &Pool) -> TestChunk {
1753        let depth = chunk.depth();
1754        TestChunk::spill_body(chunk.into_body(), pool, depth)
1755    }
1756
1757    /// The batcher size logger reports a body's bytes wherever the body
1758    /// lives, so an operator's share does not vanish when a chunk spills.
1759    #[mz_ore::test]
1760    #[cfg_attr(miri, ignore)]
1761    fn allocation_reports_a_body_wherever_it_lives() {
1762        use differential_dataflow::trace::implementations::merge_batcher::Merger;
1763
1764        let data: Vec<Tuple> = (0..64u64).map(|i| ((i, i), 0, 1)).collect();
1765        let resident = TestChunk::from_body(build_column(&data));
1766        let (size, capacity, allocations) =
1767            AccountedChunkMerger::<(u64, u64), u64, i64, ComputeSpill>::allocation(&resident);
1768        assert!(size > 0, "a resident body reports its bytes");
1769        assert_eq!((capacity, allocations), (size, 1));
1770
1771        let spilled = force_spill(resident, &test_pool());
1772        assert_eq!(
1773            AccountedChunkMerger::<(u64, u64), u64, i64, ComputeSpill>::allocation(&spilled),
1774            (size, capacity, allocations),
1775            "spilling a body does not change what its owner holds"
1776        );
1777    }
1778
1779    /// A chunk that reaches the seal resident is handed back resident, even
1780    /// with a pool installed. The readied side goes straight to a builder
1781    /// that loads every chunk, so committing it there would serialize a body
1782    /// the next call copies out again.
1783    ///
1784    /// A body the batcher spilled earlier, while merging chains it was
1785    /// holding, passes through as it is: that spill is the one worth paying
1786    /// for, and undoing it would be another copy.
1787    #[mz_ore::test]
1788    #[cfg_attr(miri, ignore)]
1789    fn seal_readies_resident_chunks() {
1790        use differential_dataflow::trace::Batcher;
1791
1792        // One push, so nothing merges before the seal and the chunk reaches
1793        // `extract` resident. Comfortably past the 64 KiB spill floor at 32
1794        // bytes per record, so a committing settle would spill it.
1795        let data: Vec<Tuple> = (0..20_000u64).map(|i| ((i, i), 0, 1)).collect();
1796
1797        set_spill_override(Some(test_pool()));
1798        let mut batcher = AccountedChunkBatcher::<(u64, u64), u64, i64, ComputeSpill>::new(None, 0);
1799        batcher.push_into(TestChunk::from_body(build_column(&data)));
1800        let (chain, _description) = batcher.seal(Antichain::new());
1801        set_spill_override(None);
1802
1803        assert!(!chain.is_empty(), "the seal ships what was pushed");
1804        assert!(
1805            chain.iter().all(|chunk| !chunk.is_spilled()),
1806            "a chunk that arrived resident is readied resident"
1807        );
1808    }
1809
1810    /// A single pool shared by every test in the module. A pool reserves a
1811    /// large slab of address space, so one per test (let alone per proptest
1812    /// case) exhausts the VM map under parallel test threads.
1813    fn test_pool() -> Pool {
1814        static POOL: std::sync::OnceLock<Pool> = std::sync::OnceLock::new();
1815        POOL.get_or_init(|| Pool::new().expect("pool creation"))
1816            .clone()
1817    }
1818
1819    proptest! {
1820        /// A full batcher round trip: push chunked inputs, seal everything,
1821        /// and compare with the reference consolidation of the union.
1822        #[mz_ore::test]
1823        #[cfg_attr(miri, ignore)]
1824        fn batcher_round_trip(
1825            inputs in prop::collection::vec(arb_consolidated(), 1..6),
1826            cuts in prop::collection::vec(0usize..7, 0..8),
1827        ) {
1828            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1829            let mut union = Vec::new();
1830            for input in &inputs {
1831                Extend::extend(&mut union, input.iter().copied());
1832                for chunk in chunked(input, &cuts) {
1833                    batcher.push_into(chunk);
1834                }
1835            }
1836            // An empty upper ships everything.
1837            let (sealed, _description) = batcher.seal(Antichain::new());
1838            prop_assert_eq!(collect_chunks(sealed), consolidate(union));
1839        }
1840
1841        /// The same round trip over force-spilled inputs: merge and extract
1842        /// read bodies back from the pool call-scoped.
1843        #[mz_ore::test]
1844        #[cfg_attr(miri, ignore)]
1845        fn batcher_round_trip_spilled(
1846            inputs in prop::collection::vec(arb_consolidated(), 1..4),
1847            cuts in prop::collection::vec(0usize..7, 0..6),
1848        ) {
1849            let pool = test_pool();
1850            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1851            let mut union = Vec::new();
1852            for input in &inputs {
1853                Extend::extend(&mut union, input.iter().copied());
1854                for chunk in chunked_spilled(input, &cuts, &pool) {
1855                    batcher.push_into(chunk);
1856                }
1857            }
1858            let (sealed, _description) = batcher.seal(Antichain::new());
1859            prop_assert_eq!(collect_chunks(sealed), consolidate(union));
1860        }
1861
1862        /// Sealing at an intermediate upper partitions by time and reports
1863        /// the kept lower envelope as the frontier.
1864        #[mz_ore::test]
1865        #[cfg_attr(miri, ignore)]
1866        fn seal_partitions_by_time(
1867            input in arb_consolidated(),
1868            cuts in prop::collection::vec(0usize..7, 0..8),
1869            upper in 0u64..5,
1870        ) {
1871            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1872            for chunk in chunked(&input, &cuts) {
1873                batcher.push_into(chunk);
1874            }
1875            let (shipped, _) = batcher.seal(Antichain::from_elem(upper));
1876            let expected_shipped: Vec<Tuple> =
1877                input.iter().copied().filter(|(_, t, _)| *t < upper).collect();
1878            prop_assert_eq!(collect_chunks(shipped), consolidate(expected_shipped));
1879
1880            let kept_min = input.iter().filter(|(_, t, _)| *t >= upper).map(|(_, t, _)| *t).min();
1881            let frontier = batcher.frontier().to_owned();
1882            prop_assert_eq!(frontier.elements().first().copied(), kept_min);
1883
1884            let (rest, _) = batcher.seal(Antichain::new());
1885            let expected_rest: Vec<Tuple> =
1886                input.iter().copied().filter(|(_, t, _)| *t >= upper).collect();
1887            prop_assert_eq!(collect_chunks(rest), consolidate(expected_rest));
1888        }
1889
1890        /// The intermediate-upper partition of `seal_partitions_by_time`, over
1891        /// force-spilled inputs: bodies read back from the pool and split by
1892        /// time in one seal.
1893        #[mz_ore::test]
1894        #[cfg_attr(miri, ignore)]
1895        fn seal_partitions_by_time_spilled(
1896            input in arb_consolidated(),
1897            cuts in prop::collection::vec(0usize..7, 0..8),
1898            upper in 0u64..5,
1899        ) {
1900            let pool = test_pool();
1901            let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
1902            for chunk in chunked_spilled(&input, &cuts, &pool) {
1903                batcher.push_into(chunk);
1904            }
1905            let (shipped, _) = batcher.seal(Antichain::from_elem(upper));
1906            let expected_shipped: Vec<Tuple> =
1907                input.iter().copied().filter(|(_, t, _)| *t < upper).collect();
1908            prop_assert_eq!(collect_chunks(shipped), consolidate(expected_shipped));
1909
1910            let kept_min = input.iter().filter(|(_, t, _)| *t >= upper).map(|(_, t, _)| *t).min();
1911            let frontier = batcher.frontier().to_owned();
1912            prop_assert_eq!(frontier.elements().first().copied(), kept_min);
1913
1914            let (rest, _) = batcher.seal(Antichain::new());
1915            let expected_rest: Vec<Tuple> =
1916                input.iter().copied().filter(|(_, t, _)| *t >= upper).collect();
1917            prop_assert_eq!(collect_chunks(rest), consolidate(expected_rest));
1918        }
1919
1920        /// `advance` equals per-record time advancement plus reference
1921        /// consolidation, including across a `done = false` carry.
1922        #[mz_ore::test]
1923        #[cfg_attr(miri, ignore)]
1924        fn advance_matches_reference(
1925            input in arb_consolidated(),
1926            cuts in prop::collection::vec(0usize..7, 0..8),
1927            frontier_elem in 0u64..5,
1928        ) {
1929            let frontier = Antichain::from_elem(frontier_elem);
1930            let mut chunks = chunked(&input, &cuts);
1931            let mut out = VecDeque::new();
1932            TestChunk::advance(&mut chunks, frontier.borrow(), false, &mut out);
1933            TestChunk::advance(&mut chunks, frontier.borrow(), true, &mut out);
1934            prop_assert!(chunks.is_empty());
1935
1936            let expected = consolidate(
1937                input
1938                    .iter()
1939                    .map(|&(d, mut t, r)| {
1940                        t.advance_by(frontier.borrow());
1941                        (d, t, r)
1942                    })
1943                    .collect(),
1944            );
1945            prop_assert_eq!(collect_chunks(out), expected);
1946        }
1947
1948        /// `settle` preserves contents and order, moves everything on `done`,
1949        /// and coalesces small neighbors.
1950        #[mz_ore::test]
1951        #[cfg_attr(miri, ignore)]
1952        fn settle_preserves_and_packs(
1953            input in arb_consolidated(),
1954            cuts in prop::collection::vec(0usize..7, 1..8),
1955        ) {
1956            let mut chunks = chunked(&input, &cuts);
1957            let mut out = VecDeque::new();
1958            TestChunk::settle(&mut chunks, true, &mut out);
1959            prop_assert!(chunks.is_empty());
1960            // Test chunks are far below the byte threshold, so maximal
1961            // packing coalesces everything into a single chunk.
1962            prop_assert!(out.len() <= 1);
1963            prop_assert_eq!(collect_chunks(out), input);
1964        }
1965
1966        /// `ChunkBatch::extract_into` over sorted, deduplicated probe keys
1967        /// equals the reference filter, resident and spilled alike, straddled
1968        /// keys included.
1969        #[mz_ore::test]
1970        #[cfg_attr(miri, ignore)]
1971        fn unload_extract_matches_filter(
1972            input in arb_consolidated(),
1973            cuts in prop::collection::vec(0usize..7, 0..8),
1974            probe_keys in prop::collection::btree_set(0u64..6, 0..6),
1975            spill in any::<bool>(),
1976        ) {
1977            prop_assume!(!input.is_empty());
1978            let pool = test_pool();
1979            let chunks: Vec<TestChunk> = if spill {
1980                chunked_spilled(&input, &cuts, &pool).into()
1981            } else {
1982                chunked(&input, &cuts).into()
1983            };
1984            let description = Description::new(
1985                Antichain::from_elem(0u64),
1986                Antichain::new(),
1987                Antichain::from_elem(0u64),
1988            );
1989            let batch = ChunkBatch::new(chunks, description);
1990
1991            let mut probe_col = <u64 as Columnar>::Container::default();
1992            for key in &probe_keys {
1993                probe_col.push(*key);
1994            }
1995            let mut staging = <Tuple as Columnar>::Container::default();
1996            batch.extract_into(probe_col.borrow(), &mut staging);
1997
1998            let expected: Vec<Tuple> = input
1999                .iter()
2000                .copied()
2001                .filter(|((k, _), _, _)| probe_keys.contains(k))
2002                .collect();
2003            prop_assert_eq!(collect_staging(&staging), expected);
2004
2005            // The scan path reproduces the batch exactly, resident and
2006            // spilled alike.
2007            let mut staging = <Tuple as Columnar>::Container::default();
2008            batch.fetch_into(&mut staging);
2009            prop_assert_eq!(collect_staging(&staging), input);
2010        }
2011    }
2012
2013    /// `locate` answers the three-way span comparison for every probe
2014    /// placement: below, within, and past the chunk's keys.
2015    #[mz_ore::test]
2016    fn locate_spans_keys() {
2017        let chunk = TestChunk::from_body(build_column(&[
2018            ((2, 0), 0, 1),
2019            ((4, 0), 0, 1),
2020            ((6, 0), 0, 1),
2021        ]));
2022        let mut probe_col = <u64 as Columnar>::Container::default();
2023        for key in [0u64, 2, 3, 6, 9] {
2024            probe_col.push(key);
2025        }
2026        let probes = probe_col.borrow();
2027        use std::cmp::Ordering::*;
2028        let expected = [Less, Equal, Equal, Equal, Greater];
2029        for (index, expected) in expected.iter().enumerate() {
2030            assert_eq!(chunk.locate(probes, index), *expected, "probe {index}");
2031        }
2032    }
2033
2034    /// Collect chunk contents while asserting each chunk's serialized size
2035    /// stays within `bound` bytes.
2036    fn collect_bounded(chunks: impl IntoIterator<Item = TestChunk>, bound: usize) -> Vec<Tuple> {
2037        let mut collected = Vec::new();
2038        for chunk in chunks {
2039            let col = chunk.into_body();
2040            let bytes = col.length_in_bytes();
2041            assert!(bytes <= bound, "chunk of {bytes} bytes exceeds {bound}");
2042            Extend::extend(&mut collected, collect_column(&col));
2043        }
2044        collected
2045    }
2046
2047    type WideUpdate = ((u64, String), u64, i64);
2048    type WideChunk = ColumnChunk<(u64, String), u64, i64, ComputeSpill>;
2049
2050    fn wide_column(keys: impl Iterator<Item = u64>, bytes: usize) -> ColumnBody<WideUpdate> {
2051        let mut column = ColumnBody::default();
2052        for key in keys {
2053            column.push_into(&((key, "x".repeat(bytes)), 0, 1));
2054        }
2055        column
2056    }
2057
2058    fn assert_wide_byte_bound(chunks: VecDeque<WideChunk>, expected: usize, payload_bytes: usize) {
2059        let mut keys = Vec::new();
2060        for chunk in chunks {
2061            let column = chunk.into_body();
2062            assert!(
2063                column.length_in_bytes() <= COMMIT_BYTES || column.len() == 1,
2064                "{} bytes in a {}-record chunk",
2065                column.length_in_bytes(),
2066                column.len(),
2067            );
2068            let view = column.borrow();
2069            for index in 0..view.len() {
2070                let ((key, payload), time, diff) = view.get(index);
2071                assert_eq!(payload.len(), payload_bytes);
2072                assert!(payload.iter().all(|byte| *byte == b'x'));
2073                assert_eq!((*time, *diff), (0, 1));
2074                keys.push(*key);
2075            }
2076        }
2077        assert_eq!(keys, (0..u64::cast_from(expected)).collect::<Vec<_>>());
2078    }
2079
2080    #[mz_ore::test]
2081    fn chunker_enforces_byte_bound() {
2082        let mut chunker = ChunkChunker::default();
2083        let mut input: Column<WideUpdate> = wide_column((0..4000).rev(), 3000).into();
2084        chunker.push_into(&mut input);
2085        let mut chunks = VecDeque::new();
2086        if let Some(chunk) = chunker.extract() {
2087            chunks.push_back(std::mem::take(chunk));
2088        }
2089        while let Some(chunk) = chunker.finish() {
2090            chunks.push_back(std::mem::take(chunk));
2091        }
2092        assert_wide_byte_bound(chunks, 4000, 3000);
2093    }
2094
2095    #[mz_ore::test]
2096    fn merge_settle_enforces_byte_bound() {
2097        let mut left = VecDeque::from([WideChunk::from_body(wide_column(
2098            (0..2000).step_by(2),
2099            2100,
2100        ))]);
2101        let mut right = VecDeque::from([WideChunk::from_body(wide_column(
2102            (1..2000).step_by(2),
2103            2100,
2104        ))]);
2105        let mut merged = VecDeque::new();
2106        while !left.is_empty() && !right.is_empty() {
2107            WideChunk::merge(&mut left, &mut right, &mut merged);
2108        }
2109        merged.append(&mut left);
2110        merged.append(&mut right);
2111        let mut settled = VecDeque::new();
2112        WideChunk::settle(&mut merged, true, &mut settled);
2113        assert_wide_byte_bound(settled, 2000, 2100);
2114    }
2115
2116    #[mz_ore::test]
2117    fn settle_enforces_byte_bound_after_coalescing() {
2118        let mut input = VecDeque::from([
2119            WideChunk::from_body(wide_column(0..400, 3000)),
2120            WideChunk::from_body(wide_column(400..800, 3000)),
2121        ]);
2122        let mut settled = VecDeque::new();
2123        WideChunk::settle(&mut input, true, &mut settled);
2124        assert_wide_byte_bound(settled, 800, 3000);
2125    }
2126
2127    #[mz_ore::test]
2128    fn settle_byte_bound_allows_indivisible_update() {
2129        let mut input = VecDeque::from([WideChunk::from_body(wide_column(0..1, 2 * COMMIT_BYTES))]);
2130        let mut settled = VecDeque::new();
2131        WideChunk::settle(&mut input, true, &mut settled);
2132        assert_wide_byte_bound(settled, 1, 2 * COMMIT_BYTES);
2133    }
2134
2135    /// One record just under the bound among many narrow ones, so a cut at
2136    /// the average width leaves the piece holding the wide record over the
2137    /// bound.
2138    #[mz_ore::test]
2139    fn push_bounded_splits_uneven_widths() {
2140        let mut column: ColumnBody<WideUpdate> = ColumnBody::default();
2141        column.push_into(&((0, "x".repeat(COMMIT_BYTES - 4096)), 0, 1));
2142        for key in 1..20_000u64 {
2143            column.push_into(&((key, "x".to_string()), 0, 1));
2144        }
2145        assert!(column.length_in_bytes() > COMMIT_BYTES);
2146        let mut out = VecDeque::new();
2147        WideChunk::push_bounded(column, 3, &mut out);
2148        let mut keys = Vec::new();
2149        for chunk in out {
2150            let ColumnChunk::Resident(_, depth) = &chunk else {
2151                panic!("pieces are resident");
2152            };
2153            assert_eq!(*depth, 3, "pieces keep the input's depth");
2154            let column = chunk.into_body();
2155            assert!(
2156                column.length_in_bytes() <= COMMIT_BYTES || column.len() == 1,
2157                "{} bytes in a {}-record piece",
2158                column.length_in_bytes(),
2159                column.len(),
2160            );
2161            let view = column.borrow();
2162            for index in 0..view.len() {
2163                let ((key, _), _, _) = view.get(index);
2164                keys.push(*key);
2165            }
2166        }
2167        assert_eq!(keys, (0..20_000u64).collect::<Vec<_>>());
2168    }
2169
2170    /// Each side is under the bound, so any oversized output would have to
2171    /// come from `merge_from` itself. One input interleaves record by record,
2172    /// the other has runs long enough to gallop.
2173    #[mz_ore::test]
2174    fn merge_output_within_byte_bound_before_settle() {
2175        let interleaved = (
2176            wide_column((0..1600).step_by(2), 2100),
2177            wide_column((1..1600).step_by(2), 2100),
2178        );
2179        let runs = (
2180            wide_column((0..500).chain(1000..1100), 2100),
2181            wide_column(500..1000, 2100),
2182        );
2183        for (left, right) in [interleaved, runs] {
2184            let expected = left.len() + right.len();
2185            let mut left = VecDeque::from([WideChunk::from_body(left)]);
2186            let mut right = VecDeque::from([WideChunk::from_body(right)]);
2187            let mut merged = VecDeque::new();
2188            while !left.is_empty() && !right.is_empty() {
2189                WideChunk::merge(&mut left, &mut right, &mut merged);
2190            }
2191            assert!(merged.len() > 1, "merge never cut its output");
2192            merged.append(&mut left);
2193            merged.append(&mut right);
2194            assert_wide_byte_bound(merged, expected, 2100);
2195        }
2196    }
2197
2198    #[mz_ore::test]
2199    fn settle_split_preserves_depth() {
2200        let column = Rc::new(wide_column(0..2000, 2100));
2201        let shared = Rc::clone(&column);
2202        let mut input = VecDeque::from([WideChunk::Resident(column, 3)]);
2203        let mut settled = VecDeque::new();
2204        WideChunk::settle(&mut input, true, &mut settled);
2205        assert!(settled.len() > 1, "oversized chunk was not split");
2206        for chunk in &settled {
2207            assert_eq!(chunk.depth(), 3, "split piece changed generation");
2208        }
2209        assert_eq!(shared.len(), 2000, "shared body was mutated");
2210        assert_wide_byte_bound(settled, 2000, 2100);
2211    }
2212
2213    /// The last piece of a split coalesces with a shallower neighbor and
2214    /// commits at the deeper depth.
2215    #[mz_ore::test]
2216    fn settle_split_then_coalesce_keeps_depth() {
2217        let mut input = VecDeque::from([
2218            WideChunk::Resident(Rc::new(wide_column(0..800, 3000)), 2),
2219            WideChunk::Resident(Rc::new(wide_column(800..810, 3000)), 0),
2220        ]);
2221        let mut settled = VecDeque::new();
2222        WideChunk::settle(&mut input, true, &mut settled);
2223        assert!(settled.len() > 1, "oversized chunk was not split");
2224        for chunk in &settled {
2225            assert_eq!(chunk.depth(), 2, "coalescing lost the deeper generation");
2226        }
2227        assert_wide_byte_bound(settled, 810, 3000);
2228    }
2229
2230    /// Chunks of just over half the bound: unless settle moves part of each
2231    /// chunk into the column it is accumulating, every chunk commits at about
2232    /// half a slot.
2233    #[mz_ore::test]
2234    fn settle_fills_accumulated_column_from_next_chunk() {
2235        let per_chunk = 370;
2236        let mut input: VecDeque<WideChunk> = (0..8u64)
2237            .map(|i| {
2238                let start = i * per_chunk;
2239                WideChunk::from_body(wide_column(start..start + per_chunk, 3000))
2240            })
2241            .collect();
2242        let chunk_bytes = input[0].clone().into_body().length_in_bytes();
2243        assert!(2 * chunk_bytes > COMMIT_BYTES && chunk_bytes < COMMIT_BYTES);
2244        let mut settled = VecDeque::new();
2245        WideChunk::settle(&mut input, true, &mut settled);
2246        let sizes: Vec<usize> = settled
2247            .iter()
2248            .map(|chunk| chunk.clone().into_body().length_in_bytes())
2249            .collect();
2250        let (last, rest) = sizes.split_last().expect("settled output");
2251        assert!(*last <= COMMIT_BYTES);
2252        for size in rest {
2253            assert!(
2254                *size >= COMMIT_BYTES - COMMIT_BYTES / 10 && *size <= COMMIT_BYTES,
2255                "committed {size} bytes, sizes {sizes:?}",
2256            );
2257        }
2258        assert_wide_byte_bound(settled, 8 * 370, 3000);
2259    }
2260
2261    /// Advancing a large input cuts the output into several chunks near the
2262    /// ship threshold, and their concatenation is the reference result.
2263    #[mz_ore::test]
2264    #[cfg_attr(miri, ignore)]
2265    fn advance_cuts_large_output() {
2266        let records: Vec<Tuple> = (0..300_000u64).map(|k| ((k, 0), 0, 1)).collect();
2267        let mut input = VecDeque::from([ColumnChunk::from_body(build_column(&records))]);
2268        let frontier = Antichain::from_elem(0u64);
2269        let mut out = VecDeque::new();
2270        TestChunk::advance(&mut input, frontier.borrow(), true, &mut out);
2271        assert!(input.is_empty());
2272        assert!(
2273            out.len() >= 2,
2274            "expected a cut output, got {} chunk(s)",
2275            out.len()
2276        );
2277        assert_eq!(collect_bounded(out, 2 * COMMIT_BYTES), records);
2278    }
2279
2280    /// An input that is entirely one `D` group is withheld whole as the
2281    /// carry unless `done`: none of it is provably complete.
2282    #[mz_ore::test]
2283    #[cfg_attr(miri, ignore)]
2284    fn advance_withholds_giant_group() {
2285        let records: Vec<Tuple> = (0..100u64).map(|t| ((7, 7), t, 1)).collect();
2286        let mut input: VecDeque<TestChunk> = VecDeque::new();
2287        for piece in records.chunks(30) {
2288            input.push_back(ColumnChunk::from_body(build_column(piece)));
2289        }
2290        let frontier = Antichain::from_elem(50u64);
2291        let mut out = VecDeque::new();
2292        TestChunk::advance(&mut input, frontier.borrow(), false, &mut out);
2293        assert!(out.is_empty(), "nothing may ship from a single open group");
2294        assert_eq!(input.len(), 1, "the whole input becomes one carry chunk");
2295        // Sealing the carry advances and consolidates it.
2296        TestChunk::advance(&mut input, frontier.borrow(), true, &mut out);
2297        assert!(input.is_empty());
2298        let advanced = records.iter().map(|&(d, t, r)| (d, t.max(50), r)).collect();
2299        assert_eq!(collect_chunks(out), consolidate(advanced));
2300    }
2301
2302    /// Chunks the extract frontier does not split pass through whole from
2303    /// their resident time bounds: spilled bodies land on their side still
2304    /// spilled, with no load or re-commit, and a kept chunk's minimal times
2305    /// feed the residual frontier.
2306    #[mz_ore::test]
2307    #[cfg_attr(miri, ignore)]
2308    fn extract_passes_frontier_disjoint_chunks_through() {
2309        set_spill_override(Some(test_pool()));
2310        let low: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), i % 4, 1)).collect();
2311        let high: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 6 + i % 4, 1)).collect();
2312        let spilled_chunk = |data: &[Tuple]| {
2313            let chunk = TestChunk::commit(build_column(&consolidate(data.to_vec())), 1);
2314            assert!(chunk.is_spilled());
2315            chunk
2316        };
2317
2318        // A frontier between the two chunks' time ranges: the low chunk
2319        // ships whole and the high chunk keeps whole, both still spilled
2320        // (no load, no re-commit), and the residual is the kept chunk's
2321        // minimal time.
2322        let mut input = VecDeque::from([spilled_chunk(&low), spilled_chunk(&high)]);
2323        let frontier = Antichain::from_elem(5u64);
2324        let mut residual = Antichain::new();
2325        let (mut keep, mut ship) = (VecDeque::new(), VecDeque::new());
2326        while !input.is_empty() {
2327            TestChunk::extract(
2328                &mut input,
2329                frontier.borrow(),
2330                &mut residual,
2331                &mut keep,
2332                &mut ship,
2333            );
2334        }
2335        assert_eq!(ship.len(), 1);
2336        assert!(ship[0].is_spilled(), "shipped whole: body untouched");
2337        assert_eq!(keep.len(), 1);
2338        assert!(keep[0].is_spilled(), "kept whole: body untouched");
2339        assert_eq!(residual, Antichain::from_elem(6));
2340        let shipped = ship.pop_front().unwrap().into_body();
2341        assert_eq!(collect_column(&shipped), consolidate(low));
2342        let kept = keep.pop_front().unwrap().into_body();
2343        assert_eq!(collect_column(&kept), consolidate(high));
2344        set_spill_override(None);
2345    }
2346
2347    /// Extracting a large chunk at an intermediate frontier cuts both sides
2348    /// into several chunks and partitions exactly by time.
2349    #[mz_ore::test]
2350    #[cfg_attr(miri, ignore)]
2351    fn extract_cuts_large_output() {
2352        let records: Vec<Tuple> = (0..300_000u64).map(|k| ((k, 0), k % 2, 1)).collect();
2353        let mut input = VecDeque::from([ColumnChunk::from_body(build_column(&records))]);
2354        let frontier = Antichain::from_elem(1u64);
2355        let mut residual = Antichain::new();
2356        let (mut keep, mut ship) = (VecDeque::new(), VecDeque::new());
2357        while !input.is_empty() {
2358            TestChunk::extract(
2359                &mut input,
2360                frontier.borrow(),
2361                &mut residual,
2362                &mut keep,
2363                &mut ship,
2364            );
2365        }
2366        assert!(
2367            keep.len() >= 2,
2368            "expected a cut keep side, got {} chunk(s)",
2369            keep.len()
2370        );
2371        assert!(
2372            ship.len() >= 2,
2373            "expected a cut ship side, got {} chunk(s)",
2374            ship.len()
2375        );
2376        let kept: Vec<Tuple> = records.iter().copied().filter(|r| r.1 >= 1).collect();
2377        let shipped: Vec<Tuple> = records.iter().copied().filter(|r| r.1 < 1).collect();
2378        assert_eq!(collect_bounded(keep, 2 * COMMIT_BYTES), kept);
2379        assert_eq!(collect_bounded(ship, 2 * COMMIT_BYTES), shipped);
2380        assert_eq!(residual, Antichain::from_elem(1));
2381    }
2382
2383    /// `locate` answers from resident metadata on spilled chunks and follows
2384    /// the probe-relative-to-span convention.
2385    #[mz_ore::test]
2386    fn locate_uses_resident_bounds() {
2387        let pool = test_pool();
2388        let data: Vec<Tuple> = vec![((2, 0), 0, 1), ((4, 0), 0, 1)];
2389        let chunk = force_spill(ColumnChunk::from_body(build_column(&data)), &pool);
2390
2391        let mut probe_col = <u64 as Columnar>::Container::default();
2392        for key in [1u64, 3, 5] {
2393            probe_col.push(key);
2394        }
2395        let probes = probe_col.borrow();
2396        assert_eq!(chunk.locate(probes, 0), std::cmp::Ordering::Less);
2397        assert_eq!(chunk.locate(probes, 1), std::cmp::Ordering::Equal);
2398        assert_eq!(chunk.locate(probes, 2), std::cmp::Ordering::Greater);
2399    }
2400
2401    /// A body large enough to spill round-trips through the pool with resident
2402    /// metadata intact, and the batcher produces spilled sealed output.
2403    #[mz_ore::test]
2404    #[cfg_attr(miri, ignore)] // too slow
2405    fn spill_round_trip() {
2406        set_spill_override(Some(test_pool()));
2407
2408        let data: Vec<Tuple> = (0..40_000u64)
2409            .map(|i| ((i / 4, i % 4), i % 8, 1i64))
2410            .collect();
2411        let data = consolidate(data);
2412
2413        let column = build_column(&data);
2414        let committed = TestChunk::commit(column, 0);
2415        assert!(committed.is_spilled(), "large body must spill");
2416        assert_eq!(committed.len(), data.len());
2417        assert_eq!(collect_column(&committed.clone().into_body()), data);
2418
2419        let mut batcher: ChunkBatcher<TestChunk> = Batcher::new(None, 0);
2420        for piece in data.chunks(10_000) {
2421            batcher.push_into(ColumnChunk::from_body(build_column(piece)));
2422        }
2423        let (sealed, _) = batcher.seal(Antichain::new());
2424        assert!(
2425            sealed.iter().any(ColumnChunk::is_spilled),
2426            "sealed output should contain spilled chunks",
2427        );
2428        assert_eq!(collect_chunks(sealed), data);
2429
2430        set_spill_override(None);
2431    }
2432
2433    /// Merging spilled chains loads bodies call-scoped and consolidates
2434    /// correctly, and an untouched survivor keeps its spilled body.
2435    #[mz_ore::test]
2436    #[cfg_attr(miri, ignore)] // too slow
2437    fn merge_spilled_chains() {
2438        set_spill_override(Some(test_pool()));
2439
2440        let a: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 1i64)).collect();
2441        let b: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 2i64)).collect();
2442
2443        let mut in1 = VecDeque::from([TestChunk::commit(build_column(&a), 0)]);
2444        let mut in2 = VecDeque::from([TestChunk::commit(build_column(&b), 0)]);
2445        assert!(in1[0].is_spilled() && in2[0].is_spilled());
2446
2447        let mut out = VecDeque::new();
2448        while !in1.is_empty() && !in2.is_empty() {
2449            TestChunk::merge(&mut in1, &mut in2, &mut out);
2450        }
2451        for tail in in1.drain(..).chain(in2.drain(..)) {
2452            out.push_back(tail);
2453        }
2454
2455        let expected: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 3i64)).collect();
2456        assert_eq!(collect_chunks(out), expected);
2457
2458        set_spill_override(None);
2459    }
2460
2461    /// A merge whose fronts have disjoint key ranges pushes the untouched
2462    /// survivor back in its original (spilled) form rather than rewriting it.
2463    #[mz_ore::test]
2464    fn merge_untouched_survivor_stays_spilled() {
2465        let pool = test_pool();
2466        let low: Vec<Tuple> = (0..100u64).map(|i| ((i, 0), 0, 1i64)).collect();
2467        let high: Vec<Tuple> = (1000..1100u64).map(|i| ((i, 0), 0, 1i64)).collect();
2468
2469        let mut in1 = VecDeque::from([force_spill(
2470            ColumnChunk::from_body(build_column(&low)),
2471            &pool,
2472        )]);
2473        let mut in2 = VecDeque::from([force_spill(
2474            ColumnChunk::from_body(build_column(&high)),
2475            &pool,
2476        )]);
2477        let mut out = VecDeque::new();
2478        TestChunk::merge(&mut in1, &mut in2, &mut out);
2479
2480        // `low` is fully consumed. `high` was never touched and must come
2481        // back spilled.
2482        assert!(in1.is_empty());
2483        assert_eq!(in2.len(), 1);
2484        assert!(in2[0].is_spilled(), "untouched survivor must stay spilled");
2485        let mut all = collect_chunks(out);
2486        Extend::extend(&mut all, collect_chunks(in2.drain(..)));
2487        let mut expected = low;
2488        Extend::extend(&mut expected, high);
2489        assert_eq!(all, expected);
2490    }
2491
2492    /// Merge output is one generation past its deepest input, a survivor
2493    /// rewritten from its remainder keeps its own depth, and a chunk passed
2494    /// through the disjoint fast path ages by its survival.
2495    #[mz_ore::test]
2496    fn merge_derives_generational_depth() {
2497        let low: Vec<Tuple> = (0..100u64).map(|i| ((i, 0), 0, 1i64)).collect();
2498        let high: Vec<Tuple> = (50..150u64).map(|i| ((i, 0), 0, 1i64)).collect();
2499        let mut in1 = VecDeque::from([ColumnChunk::from_body(build_column(&low))]);
2500        let mut in2 = VecDeque::from([ColumnChunk::from_body(build_column(&high))]);
2501        assert_eq!(in1[0].depth(), 0, "fresh chunks start at depth 0");
2502        let mut out = VecDeque::new();
2503        TestChunk::merge(&mut in1, &mut in2, &mut out);
2504        assert!(!out.is_empty());
2505        for chunk in &out {
2506            assert_eq!(chunk.depth(), 1, "merge output is one past its inputs");
2507        }
2508        // The merge runs through the shared horizon, so `high` survives with
2509        // its unmerged remainder at its original depth.
2510        assert!(in1.is_empty());
2511        assert_eq!(in2.len(), 1);
2512        assert_eq!(in2[0].depth(), 0, "rewritten survivor keeps its depth");
2513
2514        // A disjoint merge moves the lower front to the output with its data
2515        // unchanged, one generation older for having outlived the merge.
2516        let mut in1 = VecDeque::from([ColumnChunk::Resident(Rc::new(build_column(&low)), 3)]);
2517        let far: Vec<Tuple> = (1000..1100u64).map(|i| ((i, 0), 0, 1i64)).collect();
2518        let mut in2 = VecDeque::from([ColumnChunk::from_body(build_column(&far))]);
2519        let mut out = VecDeque::new();
2520        TestChunk::merge(&mut in1, &mut in2, &mut out);
2521        assert_eq!(out.len(), 1);
2522        assert_eq!(out[0].depth(), 4, "pass-through ages a generation");
2523        assert_eq!(collect_chunks(out), low);
2524    }
2525
2526    /// Advance output and carry keep the deepest input depth, since
2527    /// compaction rewrites within a generation.
2528    #[mz_ore::test]
2529    fn advance_preserves_depth() {
2530        let data: Vec<Tuple> = (0..100u64).map(|i| ((i, 0), 1, 1i64)).collect();
2531        let mut input = VecDeque::from([
2532            ColumnChunk::Resident(Rc::new(build_column(&data[..50])), 2),
2533            ColumnChunk::Resident(Rc::new(build_column(&data[50..])), 1),
2534        ]);
2535        let frontier = Antichain::from_elem(5u64);
2536        let mut out = VecDeque::new();
2537        TestChunk::advance(&mut input, frontier.borrow(), false, &mut out);
2538        for chunk in out.iter().chain(input.iter()) {
2539            assert_eq!(chunk.depth(), 2);
2540        }
2541        TestChunk::advance(&mut input, frontier.borrow(), true, &mut out);
2542        assert!(input.is_empty());
2543        assert!(!out.is_empty());
2544        for chunk in &out {
2545            assert_eq!(chunk.depth(), 2);
2546        }
2547    }
2548
2549    /// Settle commits at the deepest depth among coalesced chunks, and a
2550    /// commit large enough to spill carries the depth into its spilled
2551    /// metadata (and thus into the pool hints).
2552    #[mz_ore::test]
2553    #[cfg_attr(miri, ignore)] // too slow
2554    fn settle_commits_at_accumulated_depth() {
2555        set_spill_override(Some(test_pool()));
2556        let big: Vec<Tuple> = (0..60_000u64).map(|i| ((i, 0), 0, 1i64)).collect();
2557        let mut input = VecDeque::from([
2558            ColumnChunk::Resident(Rc::new(build_column(&big)), 1),
2559            ColumnChunk::Resident(Rc::new(build_column(&[((0, 0), 0, 1)])), 0),
2560            ColumnChunk::Resident(Rc::new(build_column(&[((1, 0), 0, 1)])), 2),
2561        ]);
2562        let mut out = VecDeque::new();
2563        TestChunk::settle(&mut input, true, &mut out);
2564        assert!(input.is_empty());
2565        assert_eq!(out.len(), 2);
2566        assert!(out[0].is_spilled(), "large commit must spill");
2567        assert_eq!(out[0].depth(), 1, "sole commit keeps its depth");
2568        assert!(!out[1].is_spilled(), "small commit stays resident");
2569        assert_eq!(out[1].depth(), 2, "coalesced commit takes the max depth");
2570        set_spill_override(None);
2571    }
2572
2573    /// The settle carry commits at a monotone size threshold rather than the
2574    /// periodic ship window, so mid-window chunk sizes cannot make it grow
2575    /// past the target unbounded.
2576    #[mz_ore::test]
2577    #[cfg_attr(miri, ignore)] // too slow
2578    fn settle_carry_commits_at_target() {
2579        // Two inputs fit in one slot. A third must start another chunk.
2580        let chunk_rows = u64::cast_from(800_000usize / 32);
2581        let mut input: VecDeque<TestChunk> = (0..4u64)
2582            .map(|c| {
2583                let data: Vec<Tuple> = (0..chunk_rows)
2584                    .map(|i| ((c * chunk_rows + i, 0), 0, 1i64))
2585                    .collect();
2586                ColumnChunk::from_body(build_column(&data))
2587            })
2588            .collect();
2589        let mut out = VecDeque::new();
2590        TestChunk::settle(&mut input, true, &mut out);
2591        // Catches the fixture drifting above `at_commit_size`, where settle
2592        // commits each chunk as-is and the size cap below holds vacuously.
2593        assert!(out.len() < 4, "nothing coalesced");
2594        for chunk in &out {
2595            let col = chunk.clone().into_body();
2596            assert!(
2597                col.length_in_bytes() <= COMMIT_BYTES,
2598                "settled chunk of {} bytes exceeds the commit target",
2599                col.length_in_bytes(),
2600            );
2601        }
2602        assert_eq!(
2603            collect_chunks(out).len(),
2604            usize::try_from(4 * chunk_rows).unwrap(),
2605        );
2606    }
2607
2608    #[mz_ore::test]
2609    fn small_chunks_stay_resident() {
2610        set_spill_override(Some(test_pool()));
2611        let committed = TestChunk::commit(build_column(&[((1, 1), 0, 1)]), 0);
2612        assert!(!committed.is_spilled());
2613        set_spill_override(None);
2614    }
2615
2616    /// The smallest column whose serialized size reaches `SPILL_MIN_BYTES`.
2617    /// One record less sits under the spill floor.
2618    fn column_at_spill_floor() -> (ColumnBody<Tuple>, u64) {
2619        let mut col: ColumnBody<Tuple> = ColumnBody::default();
2620        let mut n = 0u64;
2621        while col.length_in_bytes() < SPILL_MIN_BYTES {
2622            col.push_into(((n, n), 0, 1));
2623            n += 1;
2624        }
2625        (col, n)
2626    }
2627
2628    /// Bodies straddling the spill floor: one record under stays resident,
2629    /// at the floor spills.
2630    #[mz_ore::test]
2631    fn spill_floor_boundary() {
2632        set_spill_override(Some(test_pool()));
2633        let (col, n) = column_at_spill_floor();
2634        let mut under: ColumnBody<Tuple> = ColumnBody::default();
2635        for m in 0..n - 1 {
2636            under.push_into(((m, m), 0, 1));
2637        }
2638        assert!(under.length_in_bytes() < SPILL_MIN_BYTES);
2639        assert!(!TestChunk::commit(under, 0).is_spilled());
2640        assert!(TestChunk::commit(col, 0).is_spilled());
2641        set_spill_override(None);
2642    }
2643
2644    /// The compression depth floor picks the codec, not whether a body
2645    /// spills: shallow generations store at identity, the floor and deeper
2646    /// at lz4, and every depth spills and round-trips.
2647    #[mz_ore::test]
2648    fn spill_codec_depth_floor() {
2649        set_spill_override(Some(test_pool()));
2650        set_compress_min_depth_override(Some(2));
2651        // Codec identity via Debug: ZST statics and dyn vtables make
2652        // pointer comparison unreliable. The flag must agree with the codec,
2653        // since it is what decides whether a body wants migrating.
2654        let codec_name = |depth: u8| {
2655            let (codec, compressed) = codec_for_depth(depth);
2656            let name = format!("{:?}", codec);
2657            assert_eq!(compressed, name == "Lz4Codec", "flag tracks the codec");
2658            name
2659        };
2660        assert_eq!(codec_name(0), "IdentityCodec");
2661        assert_eq!(codec_name(1), "IdentityCodec");
2662        assert_eq!(codec_name(2), "Lz4Codec");
2663        assert_eq!(codec_name(u8::MAX), "Lz4Codec");
2664
2665        let data: Vec<Tuple> = (0..20_000u64).map(|i| ((i, 0), 0, 1i64)).collect();
2666        let data = consolidate(data);
2667        let column = build_column(&data);
2668        for depth in [0u8, 1, 2, 3] {
2669            let chunk = TestChunk::commit(column.clone(), depth);
2670            assert!(chunk.is_spilled(), "depth {depth} must spill");
2671            assert_eq!(collect_column(&chunk.into_body()), data);
2672        }
2673        set_spill_override(None);
2674        set_compress_min_depth_override(None);
2675
2676        // The default floor stores only fresh (depth 0) bodies at identity.
2677        set_compress_min_depth_override(Some(DEFAULT_COMPRESS_MIN_DEPTH));
2678        assert_eq!(codec_name(0), "IdentityCodec");
2679        assert_eq!(codec_name(1), "Lz4Codec");
2680        set_compress_min_depth_override(None);
2681    }
2682
2683    /// A chunk a merge carries forward untouched ages a generation, and a
2684    /// spilled body crossing the compression floor by doing so is re-spilled
2685    /// under the compressing codec. Key-disjoint input takes that path on
2686    /// every merge, so without the crossing its backlog would stay
2687    /// identity-coded for as long as it lived.
2688    #[mz_ore::test]
2689    #[cfg_attr(miri, ignore)] // too slow
2690    fn merge_survivor_crosses_compression_floor() {
2691        set_spill_override(Some(test_pool()));
2692        set_compress_min_depth_override(Some(1));
2693
2694        let low = consolidate((0..20_000u64).map(|i| ((i, 0), 0, 1i64)).collect());
2695        let far = consolidate((100_000..120_000u64).map(|i| ((i, 0), 0, 1i64)).collect());
2696        let fresh_far = || VecDeque::from([TestChunk::commit(build_column(&far), 0)]);
2697
2698        // Fresh spilled chunks sit below the floor, so both store identity
2699        // coded, and their data ranges are disjoint.
2700        let mut in1 = VecDeque::from([TestChunk::commit(build_column(&low), 0)]);
2701        let mut in2 = fresh_far();
2702        assert!(in1[0].is_spilled() && in2[0].is_spilled());
2703        assert!(
2704            !body_compressed(&in1[0]),
2705            "a fresh body below the floor is identity coded"
2706        );
2707
2708        let mut out = VecDeque::new();
2709        TestChunk::merge(&mut in1, &mut in2, &mut out);
2710        assert_eq!(out.len(), 1);
2711        let survived = out.pop_front().expect("the lower front passes through");
2712        assert_eq!(survived.depth(), 1, "survival ages across the floor");
2713        assert!(
2714            survived.is_spilled(),
2715            "the crossing re-spills, it does not evict"
2716        );
2717        assert!(
2718            body_compressed(&survived),
2719            "the survivor is re-spilled under the compressing codec"
2720        );
2721
2722        // Past the floor the next survival is a metadata-only bump: the body
2723        // is already compressed and stays where it is.
2724        let mut in1 = VecDeque::from([survived]);
2725        let mut in2 = fresh_far();
2726        let mut out = VecDeque::new();
2727        TestChunk::merge(&mut in1, &mut in2, &mut out);
2728        assert_eq!(out.len(), 1);
2729        assert_eq!(out[0].depth(), 2, "an aged survivor keeps aging");
2730        assert!(out[0].is_spilled());
2731        assert_eq!(
2732            collect_chunks(out),
2733            low,
2734            "the body reads back intact across both survivals"
2735        );
2736
2737        set_spill_override(None);
2738        set_compress_min_depth_override(None);
2739    }
2740
2741    /// Aging does not depend on holding the only reference to a body. The
2742    /// trace's compaction merger feeds `merge` clones of a source batch's
2743    /// chunks and keeps the batch alive throughout, so a shared body must
2744    /// still age. It must not be re-spilled: the other holder goes on
2745    /// storing the original whatever this reference does, and the merger
2746    /// rewrites its clone immediately.
2747    #[mz_ore::test]
2748    #[cfg_attr(miri, ignore)] // too slow
2749    fn merge_survivor_ages_while_shared() {
2750        set_spill_override(Some(test_pool()));
2751        set_compress_min_depth_override(Some(1));
2752
2753        let low = consolidate((0..20_000u64).map(|i| ((i, 0), 0, 1i64)).collect());
2754        let far = consolidate((100_000..120_000u64).map(|i| ((i, 0), 0, 1i64)).collect());
2755
2756        // The source batch's chunk, held for the whole merge as the spine
2757        // holds it.
2758        let source = TestChunk::commit(build_column(&low), 0);
2759        let ColumnChunk::Spilled(source_body, 0) = &source else {
2760            panic!("a fresh commit above the spill floor is spilled at depth 0");
2761        };
2762        let source_body = Rc::clone(source_body);
2763
2764        let mut in1 = VecDeque::from([source.clone()]);
2765        let mut in2 = VecDeque::from([TestChunk::commit(build_column(&far), 0)]);
2766        let mut out = VecDeque::new();
2767        TestChunk::merge(&mut in1, &mut in2, &mut out);
2768
2769        assert_eq!(out.len(), 1);
2770        assert_eq!(out[0].depth(), 1, "a shared body ages all the same");
2771        let ColumnChunk::Spilled(survived_body, _) = &out[0] else {
2772            panic!("the survivor stays spilled");
2773        };
2774        assert!(
2775            Rc::ptr_eq(&source_body, survived_body),
2776            "a shared body is aged in place, not re-spilled"
2777        );
2778        assert_eq!(source.depth(), 0, "the other holder is left as it was");
2779
2780        // Past the floor, where no re-spill is in question, a shared body
2781        // goes on aging rather than pinning at the crossing depth.
2782        let mut in1 = VecDeque::from([out.pop_front().expect("survivor observed above")]);
2783        let mut in2 = VecDeque::from([TestChunk::commit(build_column(&far), 0)]);
2784        let mut out = VecDeque::new();
2785        TestChunk::merge(&mut in1, &mut in2, &mut out);
2786        assert_eq!(out.len(), 1);
2787        assert_eq!(out[0].depth(), 2, "aging past the floor is not pinned");
2788        assert_eq!(collect_chunks(out), low);
2789
2790        set_spill_override(None);
2791        set_compress_min_depth_override(None);
2792    }
2793
2794    /// A migration that cannot happen when a body first qualifies is retried
2795    /// at the next survival, never consumed. Each case leaves an
2796    /// identity-coded body at or past the floor, which would be stranded
2797    /// uncompressed for the rest of its life if the test were a depth
2798    /// transition rather than the body's stored codec.
2799    #[mz_ore::test]
2800    #[cfg_attr(miri, ignore)] // too slow
2801    fn survive_merge_retries_missed_migrations() {
2802        let low = consolidate((0..20_000u64).map(|i| ((i, 0), 0, 1i64)).collect());
2803        let far = consolidate((100_000..120_000u64).map(|i| ((i, 0), 0, 1i64)).collect());
2804
2805        // Age a chunk one generation through a disjoint merge, which passes
2806        // the lower front through `survive_merge`.
2807        let survive = |chunk: TestChunk| {
2808            let mut in1 = VecDeque::from([chunk]);
2809            let mut in2 = VecDeque::from([TestChunk::commit(build_column(&far), 0)]);
2810            let mut out = VecDeque::new();
2811            TestChunk::merge(&mut in1, &mut in2, &mut out);
2812            out.pop_front().expect("the lower front passes through")
2813        };
2814
2815        // No pool installed when the body qualifies: spilling can be toggled
2816        // off at runtime while existing handles stay valid.
2817        set_spill_override(Some(test_pool()));
2818        set_compress_min_depth_override(Some(1));
2819        let chunk = TestChunk::commit(build_column(&low), 0);
2820        assert!(!body_compressed(&chunk));
2821        set_spill_override(None);
2822        let chunk = survive(chunk);
2823        assert_eq!(chunk.depth(), 1, "aging does not need a pool");
2824        assert!(!body_compressed(&chunk), "no pool, no migration");
2825        set_spill_override(Some(test_pool()));
2826        let chunk = survive(chunk);
2827        assert!(
2828            body_compressed(&chunk),
2829            "the migration retries once a pool is back"
2830        );
2831
2832        // Shared when the body qualifies: the compaction merger holds the
2833        // source batch while merging clones of its chunks.
2834        let chunk = TestChunk::commit(build_column(&low), 0);
2835        let held = chunk.clone();
2836        let chunk = survive(chunk);
2837        assert!(!body_compressed(&chunk), "shared, so not migrated");
2838        drop(held);
2839        let chunk = survive(chunk);
2840        assert!(
2841            body_compressed(&chunk),
2842            "the migration retries once the body is unshared"
2843        );
2844
2845        // The floor lowered long after the body spilled, which is what an
2846        // operator reaches for under pool pressure. Nothing here is a
2847        // transition: the body is already several generations past the new
2848        // floor when it moves.
2849        set_compress_min_depth_override(Some(8));
2850        let chunk = TestChunk::commit(build_column(&low), 3);
2851        assert!(!body_compressed(&chunk));
2852        set_compress_min_depth_override(Some(1));
2853        let chunk = survive(chunk);
2854        assert_eq!(chunk.depth(), 4);
2855        assert!(
2856            body_compressed(&chunk),
2857            "lowering the floor migrates bodies already past it"
2858        );
2859
2860        set_spill_override(None);
2861        set_compress_min_depth_override(None);
2862    }
2863
2864    /// One test, because the gates are process-global.
2865    #[mz_ore::test]
2866    #[cfg_attr(miri, ignore)]
2867    fn spill_gates_are_independent() {
2868        let installed =
2869            crate::pool_config::apply_pool_config(crate::pool_config::PoolPagerConfig {
2870                budget_bytes: 32 << 20,
2871                spill_threads: 1,
2872                eager_backing: false,
2873                rss_target_bytes: 16 << 20,
2874            });
2875        assert!(installed, "pool reservation failed");
2876        // A body at the spill floor, so the gates alone decide.
2877        let (col, _) = column_at_spill_floor();
2878        let compute = |col: &ColumnBody<Tuple>| TestChunk::commit(col.clone(), 0).is_spilled();
2879        let storage = |col: &ColumnBody<Tuple>| {
2880            ColumnChunk::<(u64, u64), u64, i64, StorageSpill>::commit(col.clone(), 0).is_spilled()
2881        };
2882        let sink = |col: &ColumnBody<Tuple>| try_spill_ref(col, 0).is_some();
2883
2884        assert!(!compute(&col), "all gates off");
2885        assert!(!storage(&col), "all gates off");
2886        assert!(!sink(&col), "all gates off");
2887
2888        set_storage_spill_enabled(true);
2889        assert!(storage(&col), "the storage gate spills storage chunks");
2890        assert!(
2891            !compute(&col),
2892            "the storage gate must not spill compute chunks"
2893        );
2894        assert!(!sink(&col), "the storage gate must not spill sink bodies");
2895        set_storage_spill_enabled(false);
2896
2897        set_compute_spill_enabled(true);
2898        assert!(compute(&col), "the compute gate spills compute chunks");
2899        assert!(
2900            !storage(&col),
2901            "the compute gate must not spill storage chunks"
2902        );
2903        assert!(!sink(&col), "the compute gate must not spill sink bodies");
2904        set_compute_spill_enabled(false);
2905
2906        set_sink_spill_enabled(true);
2907        assert!(sink(&col), "the sink gate spills sink bodies");
2908        assert!(
2909            !compute(&col),
2910            "the sink gate must not spill compute chunks"
2911        );
2912        assert!(
2913            !storage(&col),
2914            "the sink gate must not spill storage chunks"
2915        );
2916        set_sink_spill_enabled(false);
2917        set_compress_min_depth_override(None);
2918    }
2919
2920    /// Re-spilling an already-serialized body exercises the `ColumnBody::Words`
2921    /// branch of `spill_serialized` and round-trips byte-identically.
2922    #[mz_ore::test]
2923    fn spill_align_round_trip() {
2924        let pool = test_pool();
2925        let data: Vec<Tuple> = (0..64u64).map(|k| ((k, k), 0, 1)).collect();
2926        let spilled = force_spill(ColumnChunk::from_body(build_column(&data)), &pool);
2927        let column = spilled.into_body();
2928        let ColumnBody::Words(words) = &column else {
2929            panic!("a spilled body reads back as ColumnBody::Words");
2930        };
2931        let words = words.clone();
2932        let respilled = force_spill(ColumnChunk::from_body(column), &pool);
2933        let reread = respilled.into_body();
2934        let ColumnBody::Words(words2) = &reread else {
2935            panic!("a spilled body reads back as ColumnBody::Words");
2936        };
2937        assert_eq!(&words, words2, "byte-identical round trip");
2938        assert_eq!(collect_column(&reread), data);
2939    }
2940
2941    #[mz_ore::test]
2942    fn merge_depth_saturates() {
2943        let a = ColumnChunk::Resident(
2944            Rc::new(build_column(&[((1, 0), 0, 1), ((3, 0), 0, 1)])),
2945            u8::MAX,
2946        );
2947        let b = ColumnChunk::Resident(
2948            Rc::new(build_column(&[((2, 0), 0, 1), ((4, 0), 0, 1)])),
2949            u8::MAX,
2950        );
2951        let mut in1 = VecDeque::from([a]);
2952        let mut in2 = VecDeque::from([b]);
2953        let mut out = VecDeque::new();
2954        TestChunk::merge(&mut in1, &mut in2, &mut out);
2955        for chunk in out.iter().chain(in1.iter()).chain(in2.iter()) {
2956            assert_eq!(chunk.depth(), u8::MAX, "depth saturates");
2957        }
2958    }
2959
2960    #[mz_ore::test]
2961    fn into_body_copies_shared_resident() {
2962        let data: Vec<Tuple> = vec![((1, 1), 0, 1), ((2, 2), 0, 1)];
2963        let a = TestChunk::from_body(build_column(&data));
2964        let b = a.clone();
2965        assert_eq!(collect_column(&a.into_body()), data);
2966        assert_eq!(collect_column(&b.into_body()), data);
2967    }
2968}