vortex_layout/layouts/indexed/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! A prototype of the `vortex.indexed` layout proposed in
5//! [vortex-data/vortex#9024](https://github.com/vortex-data/vortex/issues/9024).
6//!
7//! A `vortex.indexed` layout wraps a data layout with zero or more *locating indexes* held as
8//! auxiliary children. Writers build indexes through the pluggable [`IndexVTable`] registry while
9//! streaming; readers probe those indexes to prune (or outright answer) filter predicates, and
10//! fall back to a plain scan of the data child whenever an index is missing, unknown, or has no
11//! claim on the expression.
12//!
13//! Indexes are optional at every stage. A builder that finds nothing worth keeping declines once
14//! the stream is drained — see [`IndexBuilder::finish`] — and if every builder declines, no wrapper
15//! is written at all and the file carries the plain data layout.
16//!
17//! # Shape
18//!
19//! ```text
20//! vortex.indexed
21//! ├── child 0: data Transparent("data")
22//! ├── child 1: index #0 Auxiliary("index:<id>")
23//! └── child n: index #n Auxiliary("index:<id>")
24//! ```
25//!
26//! Index content is written through an ordinary layout strategy, so it is chunked, zone-mapped,
27//! and compressed by the same machinery as data. Probing an index prunes the index child's chunks
28//! through the generic reader, so a sorted key column's zone map narrows the probe to a handful of
29//! chunks, and each surviving chunk is decoded at most once per reader, however many expressions
30//! probe it.
31//!
32//! # Serialization
33//!
34//! An indexed layout is serialized in three layers, each with one place that defines it:
35//!
36//! - **The layout's own metadata**, the [`IndexedMetadata`] protobuf behind a version byte: one
37//! spec per index child, recording the kind's id, its options blob, the index child's dtype and
38//! its partitioning. Pinned by the `indexed.metadata` goldenfile.
39//! - **Each kind's options and content**, defined by the kind's [`IndexVTable`] and nothing else:
40//! `serialize_options`/`deserialize_options`, the schema `index_dtype`, and `encode`/`decode`
41//! between the kind's chunks and rows of that schema. Builders hand the writer chunks, never
42//! bytes. `test_harness::check_roundtrip` checks a kind's pairs without writing a layout.
43//! - **The index child's layout**, which is whatever layout strategy the writer chose, serialized
44//! like any other layout. Any layout is allowed, so long as its dtype is the spec's.
45//!
46//! The dtype is what ties the layers together, and it is checked at every boundary: the writer
47//! rejects an encoded chunk that differs from the kind's declared dtype, deserializing rejects an
48//! index child that differs from its spec's dtype, and a reader skips an index whose spec's dtype
49//! the kind no longer declares, so a kind can change its schema without misreading old files.
50//!
51//! # Nesting
52//!
53//! Both child slots hold an ordinary [`LayoutRef`], so nesting is not special-cased anywhere in
54//! this module — it falls out of every child being read and written through the generic
55//! [`crate::LayoutReader`] and [`crate::LayoutStrategy`] traits:
56//!
57//! - **Data child.** Slot 0 can be any layout — chunked, zoned, struct, flat, or another
58//! `vortex.indexed` — since [`writer::IndexedStrategy`] writes it through a plain
59//! `Arc<dyn LayoutStrategy>` and `IndexedReader` reads it back through the generic
60//! reader trait rather than assuming a concrete kind. A data child that is itself wrapped in
61//! `vortex.indexed` prunes with its own indexes when the outer wrapper delegates a
62//! `pruning_evaluation`/`filter_evaluation` call to it, so a struct layout whose fields are
63//! independently indexed, or two stacked `vortex.indexed` layers with different index kinds,
64//! both work without this layout knowing about it.
65//! - **Index child.** Index content is written through the same kind of `Arc<dyn LayoutStrategy>`
66//! and probed through the generic reader trait, so it too can be chunked, zone-mapped, or itself
67//! wrapped in `vortex.indexed`. Wrapping an index child in another index (for example, a small
68//! index over keys that point into a large posting list) prunes the probe the same way it would
69//! prune a plain data scan.
70//! - **`vortex.indexed` as someone else's child.** Since it is an ordinary [`LayoutRef`], this
71//! layout is not restricted to the top of a file — it can equally be a struct field, one chunk
72//! of a chunked layout, or the child of any other layout that composes over children.
73//!
74//! The one thing that does not nest is the [`IndexSession`] registry itself: every level of
75//! nesting is resolved against the same session passed down from the reader, so an index kind
76//! unregistered at any level degrades to inert there, independent of the other levels.
77//!
78//! # What ships here
79//!
80//! The generic wrapper only: [`Indexed`], [`writer::IndexedStrategy`] and `IndexedReader`,
81//! plus the [`IndexVTable`] contract that index kinds implement. Concrete kinds are registered into
82//! an [`session::IndexSession`] — see the `reverse_index` example (`examples/reverse_index/`) for
83//! a worked example, a minimal equality index over any column.
84
85pub mod index;
86pub(crate) mod reader;
87pub mod session;
88#[cfg(any(test, feature = "_test-harness"))]
89pub mod test_harness;
90pub mod writer;
91
92use std::sync::Arc;
93
94use prost::Message;
95use roaring::RoaringBitmap;
96use vortex_array::DeserializeMetadata;
97use vortex_array::SerializeMetadata;
98use vortex_array::dtype::DType;
99use vortex_array::dtype::proto::dtype as pb;
100use vortex_error::VortexExpect;
101use vortex_error::VortexResult;
102use vortex_error::vortex_bail;
103use vortex_error::vortex_ensure;
104use vortex_error::vortex_err;
105use vortex_error::vortex_panic;
106use vortex_session::VortexSession;
107use vortex_session::registry::CachedId;
108
109pub use self::index::BuiltIndex;
110pub use self::index::IndexBuilder;
111pub use self::index::IndexExactness;
112pub use self::index::IndexId;
113pub use self::index::IndexQueryPlan;
114pub use self::index::IndexVTable;
115pub use self::index::IndexVTableRef;
116pub use self::index::RowLocator;
117pub use self::session::IndexSession;
118pub use self::session::IndexSessionExt;
119pub use self::writer::IndexConfig;
120pub use self::writer::IndexedStrategy;
121use crate::Layout;
122use crate::LayoutChildType;
123use crate::LayoutDeserializeArgs;
124use crate::LayoutId;
125use crate::LayoutParts;
126use crate::LayoutReaderContext;
127use crate::LayoutReaderRef;
128use crate::LayoutRef;
129use crate::VTable;
130use crate::children::layout_children;
131use crate::layouts::indexed::reader::IndexedReader;
132use crate::segments::SegmentSource;
133
134/// Registry id of this layout encoding.
135pub const INDEXED_LAYOUT_ID: &str = "vortex.indexed";
136
137/// Leading byte of the serialized metadata, so the protobuf can be re-shaped later.
138const INDEXED_METADATA_VERSION: u8 = 1;
139
140/// Layout vtable for the `vortex.indexed` layout.
141#[derive(Clone, Debug)]
142pub struct Indexed;
143
144/// How an index's rows are split into independently built and probed partitions.
145///
146/// Partition `p` covers data rows `p * partition_len .. (p + 1) * partition_len` (the last one
147/// clipped to the data row count) and index-child rows `index_ends[p - 1] .. index_ends[p]`. Its
148/// locators are local to the partition, so a probe restricted to one partition reads only that
149/// partition's slice of the index child.
150///
151/// This is what lets an index's granularity differ from the data child's chunking, the same way a
152/// zone map's `zone_len` does.
153#[derive(Clone, Debug)]
154pub struct IndexPartitioning {
155 partition_len: u64,
156 index_ends: Arc<[u64]>,
157 declined: RoaringBitmap,
158}
159
160impl IndexPartitioning {
161 /// Create a partitioning from each partition's exclusive end row in the index child, and the
162 /// partitions whose builder declined.
163 pub fn new(partition_len: u64, index_ends: Vec<u64>, declined: RoaringBitmap) -> Self {
164 Self {
165 partition_len,
166 index_ends: index_ends.into(),
167 declined,
168 }
169 }
170
171 /// Rows of the data child covered by each partition, except possibly the last.
172 pub fn partition_len(&self) -> u64 {
173 self.partition_len
174 }
175
176 /// Each partition's exclusive end row in the index child.
177 pub fn index_ends(&self) -> &[u64] {
178 &self.index_ends
179 }
180
181 /// Partitions whose builder declined. They hold no index rows and never prune.
182 pub fn declined(&self) -> &RoaringBitmap {
183 &self.declined
184 }
185
186 fn validate(&self, id: IndexId, data_row_count: u64, index_row_count: u64) -> VortexResult<()> {
187 vortex_ensure!(
188 self.partition_len > 0,
189 "Index {id} has a zero partition length"
190 );
191 let expected = data_row_count.div_ceil(self.partition_len);
192 vortex_ensure!(
193 self.index_ends.len() as u64 == expected,
194 "Index {id} has {} partitions, expected {expected} for {data_row_count} rows at {} rows each",
195 self.index_ends.len(),
196 self.partition_len
197 );
198 vortex_ensure!(
199 self.index_ends.is_sorted(),
200 "Index {id} partition ends must be non-decreasing"
201 );
202 vortex_ensure!(
203 self.index_ends.last().copied().unwrap_or(0) == index_row_count,
204 "Index {id} partition ends do not cover its {index_row_count}-row child"
205 );
206 vortex_ensure!(
207 self.declined.max().is_none_or(|p| u64::from(p) < expected),
208 "Index {id} declines a partition past its last"
209 );
210 Ok(())
211 }
212}
213
214/// One index attached to the data child.
215#[derive(Clone, Debug)]
216pub struct IndexSpec {
217 id: IndexId,
218 options: Arc<[u8]>,
219 index_dtype: DType,
220 /// `None` means a single partition covering the whole data child.
221 partitioning: Option<IndexPartitioning>,
222 /// The resolved kind, or `None` when it is not registered in this session. An unresolved spec
223 /// is inert: its child is never probed and reads fall through to the data child.
224 vtable: Option<IndexVTableRef>,
225}
226
227impl IndexSpec {
228 /// Create a spec for an index that was just built by `vtable`.
229 pub fn new(
230 vtable: IndexVTableRef,
231 options: Vec<u8>,
232 index_dtype: DType,
233 partitioning: Option<IndexPartitioning>,
234 ) -> Self {
235 Self {
236 id: vtable.id(),
237 options: options.into(),
238 index_dtype,
239 partitioning,
240 vtable: Some(vtable),
241 }
242 }
243
244 /// How this index is partitioned, or `None` for a single partition over the whole data child.
245 pub fn partitioning(&self) -> Option<&IndexPartitioning> {
246 self.partitioning.as_ref()
247 }
248
249 /// The registry id of this index's kind.
250 pub fn id(&self) -> IndexId {
251 self.id
252 }
253
254 /// The kind-defined, self-versioned options blob.
255 pub fn options(&self) -> &[u8] {
256 &self.options
257 }
258
259 /// The dtype of this index's layout child.
260 pub fn index_dtype(&self) -> &DType {
261 &self.index_dtype
262 }
263
264 /// The resolved kind, or `None` if it is not registered in this session.
265 pub fn vtable(&self) -> Option<&IndexVTableRef> {
266 self.vtable.as_ref()
267 }
268}
269
270/// Layout-specific data for the [`IndexedLayout`].
271///
272/// Child 0 is the data, sharing this layout's dtype and row space. Children 1.. are index content,
273/// one per entry in `IndexedData::indexes`.
274#[derive(Clone, Debug)]
275pub struct IndexedData {
276 indexes: Arc<[IndexSpec]>,
277}
278
279/// A layout that attaches locating indexes to a data child.
280pub type IndexedLayout = Layout<Indexed>;
281
282impl IndexedLayout {
283 /// Assemble an indexed layout from a data child and one layout child per index spec.
284 pub fn try_new(
285 data: LayoutRef,
286 index_layouts: Vec<LayoutRef>,
287 indexes: Vec<IndexSpec>,
288 ) -> VortexResult<Self> {
289 vortex_ensure!(
290 index_layouts.len() == indexes.len(),
291 "IndexedLayout got {} index children for {} specs",
292 index_layouts.len(),
293 indexes.len()
294 );
295 for (layout, spec) in index_layouts.iter().zip(&indexes) {
296 vortex_ensure!(
297 layout.dtype() == &spec.index_dtype,
298 "Index child dtype {} does not match spec dtype {} for {}",
299 layout.dtype(),
300 spec.index_dtype,
301 spec.id
302 );
303 if let Some(partitioning) = &spec.partitioning {
304 partitioning.validate(spec.id, data.row_count(), layout.row_count())?;
305 }
306 }
307
308 let dtype = data.dtype().clone();
309 let row_count = data.row_count();
310 let mut children = Vec::with_capacity(1 + index_layouts.len());
311 children.push(data);
312 children.extend(index_layouts);
313
314 Ok(LayoutParts::new(
315 Indexed,
316 dtype,
317 row_count,
318 Vec::new(),
319 layout_children(children),
320 IndexedData {
321 indexes: indexes.into(),
322 },
323 )
324 .into_typed())
325 }
326
327 /// The indexes attached to the data child.
328 pub fn indexes(&self) -> &Arc<[IndexSpec]> {
329 &self.indexes
330 }
331}
332
333impl VTable for Indexed {
334 type LayoutData = IndexedData;
335 type Metadata = IndexedMetadata;
336
337 fn id(&self) -> LayoutId {
338 static ID: CachedId = CachedId::new(INDEXED_LAYOUT_ID);
339 *ID
340 }
341
342 fn metadata(layout: &Layout<Self>) -> Self::Metadata {
343 IndexedMetadata {
344 indexes: layout
345 .indexes
346 .iter()
347 .map(|spec| IndexSpecProto {
348 id: spec.id.to_string(),
349 options: spec.options.to_vec(),
350 index_dtype: Some(
351 pb::DType::try_from(&spec.index_dtype)
352 .vortex_expect("index child dtype should be serializable"),
353 ),
354 partition_len: spec.partitioning.as_ref().map(|p| p.partition_len),
355 partition_index_ends: spec
356 .partitioning
357 .as_ref()
358 .map(|p| p.index_ends.to_vec())
359 .unwrap_or_default(),
360 declined_partitions: spec
361 .partitioning
362 .as_ref()
363 .map(|p| p.declined.iter().collect())
364 .unwrap_or_default(),
365 })
366 .collect::<Vec<_>>()
367 .into(),
368 }
369 }
370
371 fn deserialize(
372 &self,
373 args: &LayoutDeserializeArgs<'_>,
374 metadata: &IndexedMetadata,
375 ) -> VortexResult<Self::LayoutData> {
376 vortex_ensure!(
377 args.children.nchildren() == 1 + metadata.indexes.len(),
378 "IndexedLayout expects {} children (data + {} indexes), got {}",
379 1 + metadata.indexes.len(),
380 metadata.indexes.len(),
381 args.children.nchildren()
382 );
383
384 let registry = args.session.indexes();
385 let indexes = metadata
386 .indexes
387 .iter()
388 .enumerate()
389 .map(|(idx, spec)| {
390 let index_dtype = spec
391 .index_dtype
392 .as_ref()
393 .map(|dtype| DType::from_proto(dtype, args.session))
394 .transpose()?
395 .ok_or_else(|| vortex_err!("Index spec {} is missing its dtype", spec.id))?;
396 let id = IndexId::from(spec.id.as_str());
397
398 let partitioning = spec
399 .partition_len
400 .map(|partition_len| {
401 let partitioning = IndexPartitioning::new(
402 partition_len,
403 spec.partition_index_ends.clone(),
404 spec.declined_partitions.iter().copied().collect(),
405 );
406 partitioning.validate(
407 id,
408 args.row_count,
409 args.children.child_row_count(idx + 1),
410 )?;
411 Ok::<_, vortex_error::VortexError>(partitioning)
412 })
413 .transpose()?;
414
415 // An unknown kind degrades to an inert spec rather than failing the read: the
416 // child stays addressable so child counts and dtypes still line up, but nothing
417 // ever probes it.
418 Ok(IndexSpec {
419 id,
420 options: spec.options.as_slice().into(),
421 index_dtype,
422 partitioning,
423 vtable: registry.find(&id),
424 })
425 })
426 .collect::<VortexResult<Vec<_>>>()?;
427
428 args.children.child(0, args.dtype)?;
429 for (idx, spec) in indexes.iter().enumerate() {
430 args.children.child(idx + 1, &spec.index_dtype)?;
431 }
432
433 Ok(IndexedData {
434 indexes: indexes.into(),
435 })
436 }
437
438 fn child_dtype(layout: &Layout<Self>, slot: usize) -> VortexResult<DType> {
439 match slot {
440 0 => Ok(layout.dtype().clone()),
441 _ => {
442 let Some(spec) = layout.indexes.get(slot - 1) else {
443 vortex_bail!("Invalid child index: {}", slot);
444 };
445 Ok(spec.index_dtype.clone())
446 }
447 }
448 }
449
450 fn child_type(layout: &Layout<Self>, slot: usize) -> LayoutChildType {
451 match slot {
452 0 => LayoutChildType::Transparent("data".into()),
453 _ => match layout.indexes.get(slot - 1) {
454 Some(spec) => LayoutChildType::Auxiliary(format!("index:{}", spec.id).into()),
455 None => vortex_panic!("Invalid child index: {}", slot),
456 },
457 }
458 }
459
460 fn new_reader(
461 layout: &Layout<Self>,
462 name: Arc<str>,
463 segment_source: Arc<dyn SegmentSource>,
464 session: &VortexSession,
465 ctx: &LayoutReaderContext,
466 ) -> VortexResult<LayoutReaderRef> {
467 Ok(Arc::new(IndexedReader::try_new(
468 layout.clone(),
469 name,
470 segment_source,
471 session.clone(),
472 ctx.clone(),
473 )?))
474 }
475}
476
477/// Serialized indexed-layout metadata: one entry per index child, in child order.
478#[derive(Debug, Clone, PartialEq)]
479pub struct IndexedMetadata {
480 indexes: Arc<[IndexSpecProto]>,
481}
482
483#[derive(Clone, PartialEq, Message)]
484struct IndexedMetadataProto {
485 #[prost(message, repeated, tag = "1")]
486 indexes: Vec<IndexSpecProto>,
487}
488
489#[derive(Clone, PartialEq, Message)]
490struct IndexSpecProto {
491 /// Registry id of the index kind, e.g. `vortex.idx.reverse_index`.
492 #[prost(string, tag = "1")]
493 id: String,
494 /// Kind-defined, self-versioned options.
495 #[prost(bytes = "vec", tag = "2")]
496 options: Vec<u8>,
497 /// The dtype of the index child. Layout nodes carry no dtype of their own — it flows top-down
498 /// during deserialization — so an auxiliary child's dtype has to be recorded here.
499 #[prost(message, optional, tag = "3")]
500 index_dtype: Option<pb::DType>,
501 /// Data rows per partition; absent for a single partition over the whole data child.
502 #[prost(uint64, optional, tag = "4")]
503 partition_len: Option<u64>,
504 /// Each partition's exclusive end row in the index child.
505 #[prost(uint64, repeated, tag = "5")]
506 partition_index_ends: Vec<u64>,
507 /// Partitions whose builder declined.
508 #[prost(uint32, repeated, tag = "6")]
509 declined_partitions: Vec<u32>,
510}
511
512impl SerializeMetadata for IndexedMetadata {
513 fn serialize(self) -> Vec<u8> {
514 let proto = IndexedMetadataProto {
515 indexes: self.indexes.to_vec(),
516 };
517 let mut metadata = vec![INDEXED_METADATA_VERSION];
518 metadata.extend(proto.encode_to_vec());
519 metadata
520 }
521}
522
523impl DeserializeMetadata for IndexedMetadata {
524 type Output = Self;
525
526 fn deserialize(metadata: &[u8]) -> VortexResult<Self::Output> {
527 let Some((&version, proto_bytes)) = metadata.split_first() else {
528 vortex_bail!("Indexed metadata missing protobuf version");
529 };
530 vortex_ensure!(
531 version == INDEXED_METADATA_VERSION,
532 "Unsupported indexed metadata version: {}",
533 version
534 );
535
536 let proto = IndexedMetadataProto::decode(proto_bytes)
537 .map_err(|err| vortex_err!("Failed to decode indexed metadata: {err}"))?;
538 Ok(Self {
539 indexes: proto.indexes.into(),
540 })
541 }
542}
543
544#[cfg(test)]
545mod tests;