mz_timely_util/columnar/unload.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//! [`UnloadChunk`]: the bulk-read capability for [`Chunk`] families, and the
11//! batch-level driver over it.
12//!
13//! Ported from differential's unmerged bulk-read surface
14//! (TimelyDataflow/differential-dataflow#782) so that consumers here depend
15//! on a local definition rather than a git pin. One deliberate difference
16//! from the upstream shape: the batch-level driver is the [`UnloadBatch`]
17//! extension trait rather than inherent methods on differential's
18//! [`ChunkBatch`] (an inherent impl on a foreign type is not available to
19//! this crate); the method names and signatures match, so call sites survive
20//! a future switch to upstream re-exports unchanged.
21
22use std::cmp::Ordering;
23
24use differential_dataflow::trace::chunk::{Chunk, ChunkBatch};
25
26/// Look up a sorted set of keys in a chunk, copying the matching updates out
27/// into caller-owned staging.
28///
29/// This is the bulk, copy-out way to read a chunk: extraction is finished
30/// with a chunk's body when the call returns, so a spilled body is read for
31/// the scope of one call and no reference into pool memory ever exists
32/// outside it. That contract is what lets a buffer pool evict with no reader
33/// accounting, where navigation-style access would need the body kept
34/// readable for as long as the reader holds it.
35///
36/// Callers usually read whole batches, not single chunks: the [`UnloadBatch`]
37/// extension on [`ChunkBatch`] looks up a probe set across the batch's chunk
38/// sequence, and its `fetch_into` stages the full contents (the scan path).
39/// Results land in a [`Staging`](UnloadChunk::Staging) the caller owns and
40/// consumes at leisure. Staged times are copied verbatim — advancement by a
41/// compaction frontier stays with the consumer.
42///
43/// Like [`Chunk`] itself, the trait has no key, val, time, or diff opinions:
44/// `Staging` and `Probes` are opaque types the implementing family chooses,
45/// and the one key comparison the batch driver needs is delegated to the
46/// chunk via [`locate`](UnloadChunk::locate) — which, like
47/// [`len`](Chunk::len), must be answerable from resident metadata even when
48/// the body is spilled.
49///
50/// # The consume-index protocol (implementors)
51///
52/// A batch's chunks are one globally-sorted sequence cut at arbitrary points,
53/// so a key's updates may straddle consecutive chunks.
54/// [`extract_into`](UnloadChunk::extract_into) carries that invariant as a
55/// protocol: a chunk consumes (advances `*probe_index` past) every probe
56/// strictly below its last key — extracting hits, silently passing over
57/// misses — and *extracts but does not consume* a probe equal to its last
58/// key, so the driver re-offers that probe to the next chunk, whose
59/// continuation lands in staging as a legal straddle. Consumers detect
60/// misses as keys absent from staging.
61pub trait UnloadChunk: Chunk {
62 /// Where extracted updates land: an owned accumulation of resident data,
63 /// chosen by the implementing family.
64 ///
65 /// Appends arrive in global `(key, val, time)` order; a group continuing
66 /// across appends may remain split, exactly as chunk sequences elsewhere
67 /// carry the straddle invariant.
68 type Staging: Default;
69
70 /// A sorted, deduplicated key column, borrowed from the same family —
71 /// another chunk's key column, or a synthesized probe set.
72 type Probes<'a>: Copy;
73
74 /// The number of probe keys.
75 fn probe_count(probes: Self::Probes<'_>) -> usize;
76
77 /// Where `probes[probe_index]` falls relative to this chunk's key span:
78 /// `Less` before the first key, `Equal` within `[first, last]`, `Greater`
79 /// past the last key.
80 ///
81 /// Resident metadata only — must never fetch a spilled body.
82 fn locate(&self, probes: Self::Probes<'_>, probe_index: usize) -> Ordering;
83
84 /// Append this chunk's updates for probes at and after `*probe_index`
85 /// into `staging`, advancing `*probe_index` past every probe strictly
86 /// below this chunk's last key.
87 ///
88 /// A probe *equal* to the last key is extracted but not consumed: its
89 /// group may continue in the next chunk (see the protocol above). Any
90 /// read of a spilled body is scoped to this call.
91 fn extract_into(
92 &self,
93 probes: Self::Probes<'_>,
94 probe_index: &mut usize,
95 staging: &mut Self::Staging,
96 );
97
98 /// Append the whole chunk into `staging` (the scan path).
99 fn fetch_into(&self, staging: &mut Self::Staging);
100}
101
102/// The batch-level driver over [`UnloadChunk`], as an extension of
103/// [`ChunkBatch`].
104pub trait UnloadBatch<C: UnloadChunk> {
105 /// Extract every probe hit in this batch into `staging`.
106 ///
107 /// Gallops the chunk list by [`locate`](UnloadChunk::locate) — resident
108 /// metadata only — and opens (via
109 /// [`extract_into`](UnloadChunk::extract_into)) only the chunks whose key
110 /// span contains a probe. Probes that fall in the gap between two chunks'
111 /// spans are consumed against resident metadata alone. A probe left
112 /// unconsumed at a chunk's last key is re-offered to the next chunk,
113 /// whose continuation follows in staging.
114 fn extract_into(&self, probes: C::Probes<'_>, staging: &mut C::Staging);
115
116 /// Materialize the batch's full contents into `staging` (the scan path).
117 fn fetch_into(&self, staging: &mut C::Staging);
118}
119
120impl<C: UnloadChunk> UnloadBatch<C> for ChunkBatch<C> {
121 fn extract_into(&self, probes: C::Probes<'_>, staging: &mut C::Staging) {
122 let count = C::probe_count(probes);
123 let chunks = &self.chunks[..];
124 let (mut probe_index, mut chunk) = (0usize, 0usize);
125 while probe_index < count && chunk < chunks.len() {
126 // Whether chunk `c` lies entirely below `probes[probe_index]`
127 // (its last key is smaller), read from resident metadata.
128 let below = |c: usize| chunks[c].locate(probes, probe_index) == Ordering::Greater;
129 // Gallop to the first chunk not below the probe: exponential
130 // search from the current chunk, then binary within the bracket.
131 if below(chunk) {
132 let (mut prev, mut step) = (chunk, 1usize);
133 while prev + step < chunks.len() && below(prev + step) {
134 prev += step;
135 step <<= 1;
136 }
137 let (mut a, mut b) = (prev + 1, (prev + step).min(chunks.len()));
138 while a < b {
139 let m = a + (b - a) / 2;
140 if below(m) { a = m + 1 } else { b = m }
141 }
142 chunk = a;
143 }
144 if chunk >= chunks.len() {
145 return;
146 }
147 // Consume probes in the gap below this chunk's first key: they
148 // match nothing in the batch, and deciding so from resident
149 // metadata is what keeps an untouched body unopened.
150 while probe_index < count && chunks[chunk].locate(probes, probe_index) == Ordering::Less
151 {
152 probe_index += 1;
153 }
154 if probe_index < count && chunks[chunk].locate(probes, probe_index) == Ordering::Equal {
155 chunks[chunk].extract_into(probes, &mut probe_index, staging);
156 }
157 // Everything strictly below this chunk's last key is consumed; a
158 // probe equal to it was extracted but left for the next chunk
159 // (the straddle re-offer).
160 chunk += 1;
161 }
162 }
163
164 fn fetch_into(&self, staging: &mut C::Staging) {
165 for chunk in &self.chunks {
166 chunk.fetch_into(staging);
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 //! Contract tests for the batch driver over a miniature row family:
174 //! extraction over arbitrary chunk cuts and probe placements — straddled
175 //! keys included — equals the reference filter of the raw rows.
176
177 use std::collections::VecDeque;
178
179 use differential_dataflow::trace::Description;
180 use timely::progress::Antichain;
181 use timely::progress::frontier::AntichainRef;
182
183 use super::*;
184
185 /// A sorted, consolidated run of `(key, val)` rows; the minimal family.
186 /// Only the read surface is exercised: the [`Chunk`] transducers are the
187 /// maintenance half, which the driver never invokes.
188 #[derive(Clone)]
189 struct Rows(Vec<(u64, u64)>);
190
191 impl Chunk for Rows {
192 type Time = u64;
193 const TARGET: usize = 8;
194 fn len(&self) -> usize {
195 self.0.len()
196 }
197 fn merge(_: &mut VecDeque<Self>, _: &mut VecDeque<Self>, _: &mut VecDeque<Self>) {
198 unimplemented!("maintenance is not under test")
199 }
200 fn extract(
201 _: &mut VecDeque<Self>,
202 _: AntichainRef<u64>,
203 _: &mut Antichain<u64>,
204 _: &mut VecDeque<Self>,
205 _: &mut VecDeque<Self>,
206 ) {
207 unimplemented!("maintenance is not under test")
208 }
209 fn advance(_: &mut VecDeque<Self>, _: AntichainRef<u64>, _: bool, _: &mut VecDeque<Self>) {
210 unimplemented!("maintenance is not under test")
211 }
212 fn settle(_: &mut VecDeque<Self>, _: bool, _: &mut VecDeque<Self>) {
213 unimplemented!("maintenance is not under test")
214 }
215 }
216
217 impl UnloadChunk for Rows {
218 type Staging = Vec<(u64, u64)>;
219 type Probes<'a> = &'a [u64];
220
221 fn probe_count(probes: &[u64]) -> usize {
222 probes.len()
223 }
224
225 fn locate(&self, probes: &[u64], probe_index: usize) -> Ordering {
226 let probe = probes[probe_index];
227 if probe < self.0[0].0 {
228 Ordering::Less
229 } else if probe > self.0[self.0.len() - 1].0 {
230 Ordering::Greater
231 } else {
232 Ordering::Equal
233 }
234 }
235
236 fn extract_into(
237 &self,
238 probes: &[u64],
239 probe_index: &mut usize,
240 staging: &mut Self::Staging,
241 ) {
242 let rows = &self.0[..];
243 let last = rows[rows.len() - 1].0;
244 let mut pos = 0;
245 while *probe_index < probes.len() {
246 let probe = probes[*probe_index];
247 if probe > last {
248 return;
249 }
250 while pos < rows.len() && rows[pos].0 < probe {
251 pos += 1;
252 }
253 while pos < rows.len() && rows[pos].0 == probe {
254 staging.push(rows[pos]);
255 pos += 1;
256 }
257 if probe == last {
258 return;
259 }
260 *probe_index += 1;
261 }
262 }
263
264 fn fetch_into(&self, staging: &mut Self::Staging) {
265 staging.extend_from_slice(&self.0);
266 }
267 }
268
269 fn batch_of(rows: &[(u64, u64)], cut: usize) -> ChunkBatch<Rows> {
270 let chunks: Vec<Rows> = rows.chunks(cut).map(|c| Rows(c.to_vec())).collect();
271 let description = Description::new(
272 Antichain::from_elem(0u64),
273 Antichain::new(),
274 Antichain::from_elem(0u64),
275 );
276 ChunkBatch::new(chunks, description)
277 }
278
279 /// Every (chunk cut, contiguous probe range) placement over rows with a
280 /// multi-chunk-spanning key equals the reference filter, and the scan
281 /// path reproduces the batch exactly.
282 #[mz_ore::test]
283 fn extract_matches_filter() {
284 // Even keys 0..=16; key 8 carries 6 rows so it spans chunks at every
285 // cut size below 6.
286 let mut rows: Vec<(u64, u64)> = Vec::new();
287 for k in (0..=16u64).step_by(2) {
288 let copies = if k == 8 { 6 } else { 1 };
289 for c in 0..copies {
290 rows.push((k, c));
291 }
292 }
293 for cut in 1..=5 {
294 let batch = batch_of(&rows, cut);
295 for lo in 0..=18u64 {
296 for hi in lo..=18u64 {
297 let probes: Vec<u64> = (lo..=hi).collect();
298 let mut staging = Vec::new();
299 batch.extract_into(&probes[..], &mut staging);
300 let want: Vec<_> = rows
301 .iter()
302 .filter(|r| lo <= r.0 && r.0 <= hi)
303 .copied()
304 .collect();
305 assert_eq!(staging, want, "cut={cut} probes={lo}..={hi}");
306 }
307 }
308 let mut staging = Vec::new();
309 batch.fetch_into(&mut staging);
310 assert_eq!(staging, rows, "cut={cut}");
311 }
312 }
313}