Skip to main content

vortex_layout/layouts/indexed/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! The pluggable index-kind contract: what a kind must implement to be built at write time and
5//! probed at read time.
6
7use std::any::Any;
8use std::fmt::Debug;
9use std::ops::Range;
10use std::sync::Arc;
11
12use roaring::RoaringBitmap;
13use vortex_array::ArrayRef;
14use vortex_array::ExecutionCtx;
15use vortex_array::dtype::DType;
16use vortex_array::expr::BoundExpression;
17use vortex_buffer::BitBufferMut;
18use vortex_error::VortexResult;
19use vortex_mask::Mask;
20use vortex_session::VortexSession;
21use vortex_session::registry::Id;
22
23use crate::layouts::indexed::reader::OpenArgs;
24use crate::layouts::indexed::reader::OpenIndex;
25use crate::layouts::indexed::reader::open_index;
26use crate::layouts::indexed::writer::IndexWriter;
27use crate::layouts::indexed::writer::TypedIndexWriter;
28
29/// Stable registry id of an index kind, e.g. `vortex.idx.reverse_index`.
30pub type IndexId = Id;
31
32/// A pluggable index kind.
33///
34/// Mirrors the layout `VTable` machinery: implementations are registered in an
35/// [`IndexSession`](crate::layouts::indexed::session::IndexSession) under a stable string id,
36/// which is what gets written into the layout metadata. A reader that does not have the kind
37/// registered drops the index child and reads the data child directly.
38///
39/// The associated types are what keeps repeated work out of the read path. A reader parses a
40/// spec's [`Options`](Self::Options) once, and decodes each chunk of the index child into a
41/// [`Chunk`](Self::Chunk) at most once, sharing it between every expression that probes the
42/// chunk. Each expression is reduced by [`plan`](Self::plan) to a [`Query`](Self::Query) that
43/// [`resolve`](Self::resolve) answers against those decoded chunks.
44///
45/// # Serialization
46///
47/// A kind's on-disk format is exactly three things, and nothing else a kind does reaches the file:
48///
49/// - [`serialize_options`](Self::serialize_options) and
50///   [`deserialize_options`](Self::deserialize_options), for the options blob stored in the spec.
51///   The blob is the kind's to version.
52/// - [`index_dtype`](Self::index_dtype), the schema of the index child.
53/// - [`encode`](Self::encode) and [`decode`](Self::decode), between a [`Chunk`](Self::Chunk) and
54///   rows of that schema.
55///
56/// Builders produce chunks and options, never bytes, so these pairs can be tested without
57/// writing a layout. Re-encoding a decoded chunk must reproduce its encoding.
58pub trait IndexVTable: 'static + Send + Sync + Debug {
59    /// The kind's options, parsed from the self-versioned options blob.
60    type Options: 'static + Send + Sync;
61    /// Builds the index on the write path.
62    type Builder: IndexBuilder<Options = Self::Options, Chunk = Self::Chunk> + 'static;
63    /// What [`plan`](Self::plan) reduced one expression to, answered by
64    /// [`resolve`](Self::resolve).
65    type Query: 'static + Send + Sync;
66    /// One chunk of the index child, decoded into whatever form the kind answers queries from.
67    type Chunk: 'static + Send + Sync;
68
69    /// Stable string id, e.g. `vortex.idx.reverse_index`.
70    fn id(&self) -> IndexId;
71
72    /// Serialize options into the blob stored in the spec.
73    fn serialize_options(&self, options: &Self::Options) -> Vec<u8>;
74
75    /// Parse an options blob, as configured on the write side or stored in a spec.
76    fn deserialize_options(&self, options: &[u8]) -> VortexResult<Self::Options>;
77
78    /// The dtype of the index child this kind builds over values of `dtype`, or `None` if it
79    /// cannot index them.
80    ///
81    /// This is the index's schema: the writer rejects a builder whose output differs from it, and
82    /// the reader skips an index whose stored dtype no longer matches it.
83    fn index_dtype(&self, dtype: &DType, options: &Self::Options) -> Option<DType>;
84
85    /// Construct a builder for the write path.
86    ///
87    /// `data_block_len` is the data child's repartition block size when known. Kinds that emit
88    /// block-granular locators should default their block length to it so pruned blocks line up
89    /// with chunk and segment boundaries.
90    ///
91    /// A partitioned index calls this once per partition, and each builder sees only its
92    /// partition's rows.
93    fn builder(
94        &self,
95        dtype: &DType,
96        options: &Self::Options,
97        data_block_len: Option<u64>,
98        session: &VortexSession,
99    ) -> VortexResult<Self::Builder>;
100
101    /// Decide whether this index can serve `expr`, a single conjunct scoped to the data child's
102    /// `dtype`.
103    ///
104    /// `index_dtype` is the index child's dtype, as returned by [`IndexVTable::index_dtype`], and
105    /// the scope the plan's filter must be bound to.
106    ///
107    /// `None` means "no claim" and is always safe: the scan falls back to the data child.
108    fn plan(
109        &self,
110        expr: &BoundExpression,
111        dtype: &DType,
112        index_dtype: &DType,
113        options: &Self::Options,
114    ) -> VortexResult<Option<IndexQueryPlan<Self::Query>>>;
115
116    /// Encode a chunk a builder produced as rows of the index child, of the declared
117    /// [`index_dtype`](Self::index_dtype).
118    fn encode(
119        &self,
120        chunk: &Self::Chunk,
121        options: &Self::Options,
122        ctx: &mut ExecutionCtx,
123    ) -> VortexResult<ArrayRef>;
124
125    /// Decode rows of the index child, in its own schema, back into a chunk.
126    ///
127    /// The index child's layout strategy may chunk it differently than it was encoded, so this
128    /// must accept any contiguous run of encoded rows. Called at most once per chunk per reader,
129    /// however many expressions probe it.
130    fn decode(
131        &self,
132        chunk: ArrayRef,
133        options: &Self::Options,
134        ctx: &mut ExecutionCtx,
135    ) -> VortexResult<Self::Chunk>;
136
137    /// Answer `query` from one partition's decoded chunks, as a locator local to the partition.
138    ///
139    /// `chunks` are in index-row order and omit every chunk the plan's
140    /// [`filter`](IndexQueryPlan::filter) ruled out, so they hold everything the query can match
141    /// in this partition. `data_row_count` is the partition's row count in the data child.
142    fn resolve(
143        &self,
144        query: &Self::Query,
145        chunks: &[Arc<Self::Chunk>],
146        data_row_count: u64,
147        options: &Self::Options,
148        ctx: &mut ExecutionCtx,
149    ) -> VortexResult<RowLocator>;
150}
151
152/// Shared handle to a registered index kind, erasing its [`IndexVTable`] types.
153#[derive(Clone)]
154pub struct IndexVTableRef(Arc<dyn DynIndexVTable>);
155
156impl IndexVTableRef {
157    /// Erase an index kind so it can be registered and configured alongside others.
158    pub fn new<V: IndexVTable>(vtable: V) -> Self {
159        Self(Arc::new(vtable))
160    }
161
162    /// The kind's stable id.
163    pub fn id(&self) -> IndexId {
164        self.0.id()
165    }
166
167    /// The concrete kind, if it is a `V`.
168    pub fn as_opt<V: IndexVTable>(&self) -> Option<&V> {
169        self.0.as_any().downcast_ref()
170    }
171
172    /// Parse `options` once for writing.
173    pub(crate) fn open_writer(&self, options: &[u8]) -> VortexResult<Arc<dyn IndexWriter>> {
174        Arc::clone(&self.0).open_writer(options)
175    }
176
177    /// Parse a spec's options once for reading, or `None` if its stored dtype is not what the
178    /// kind now declares.
179    pub(crate) fn open_reader(
180        &self,
181        args: OpenArgs<'_>,
182    ) -> VortexResult<Option<Arc<dyn OpenIndex>>> {
183        Arc::clone(&self.0).open_reader(args)
184    }
185}
186
187impl Debug for IndexVTableRef {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        self.0.fmt(f)
190    }
191}
192
193/// The type-erased side of [`IndexVTable`], implemented for every kind.
194///
195/// Kept crate-private so the reader and writer can hand out their own typed state without it
196/// becoming public API; kinds only ever implement [`IndexVTable`].
197pub(crate) trait DynIndexVTable: 'static + Send + Sync + Debug {
198    fn id(&self) -> IndexId;
199
200    fn as_any(&self) -> &dyn Any;
201
202    fn open_writer(self: Arc<Self>, options: &[u8]) -> VortexResult<Arc<dyn IndexWriter>>;
203
204    fn open_reader(self: Arc<Self>, args: OpenArgs<'_>)
205    -> VortexResult<Option<Arc<dyn OpenIndex>>>;
206}
207
208impl<V: IndexVTable> DynIndexVTable for V {
209    fn id(&self) -> IndexId {
210        IndexVTable::id(self)
211    }
212
213    fn as_any(&self) -> &dyn Any {
214        self
215    }
216
217    fn open_writer(self: Arc<Self>, options: &[u8]) -> VortexResult<Arc<dyn IndexWriter>> {
218        Ok(Arc::new(TypedIndexWriter::try_new(self, options)?))
219    }
220
221    fn open_reader(
222        self: Arc<Self>,
223        args: OpenArgs<'_>,
224    ) -> VortexResult<Option<Arc<dyn OpenIndex>>> {
225        open_index(self, args)
226    }
227}
228
229/// What an [`IndexBuilder`] emits on finishing: its chunks in order, and its final options.
230pub type BuiltIndex<B> = (
231    Vec<<B as IndexBuilder>::Chunk>,
232    <B as IndexBuilder>::Options,
233);
234
235/// Accumulates index content while the data stream is written.
236pub trait IndexBuilder: Send {
237    /// The kind's options, as finally chosen by the build.
238    type Options;
239    /// The content the kind's [`IndexVTable::encode`] writes out.
240    type Chunk;
241
242    /// Chunks arrive in stream order with their row offset within this builder's partition, which
243    /// for an unpartitioned index is the whole layout.
244    fn push(
245        &mut self,
246        chunk: &ArrayRef,
247        row_offset: u64,
248        ctx: &mut ExecutionCtx,
249    ) -> VortexResult<()>;
250
251    /// Emit the index content as chunks, which the writer encodes and writes through a child
252    /// layout strategy in order.
253    ///
254    /// Returns the final options alongside them, so builders can record normalization choices or
255    /// block sizes discovered during the build.
256    ///
257    /// `None` declines: nothing worth keeping was built, so no index child and no spec are written,
258    /// and the wrapper collapses to the plain data layout if every builder declines. This is the
259    /// only point at which size can be judged — a builder is constructed before the first chunk
260    /// arrives, so row count and cardinality are not knowable earlier. Declining is always safe: an
261    /// absent index reads exactly like an unregistered one.
262    fn finish(self) -> VortexResult<Option<BuiltIndex<Self>>>;
263
264    /// Bytes currently buffered, reported up through the write context's buffered-bytes tracker.
265    fn buffered_bytes(&self) -> u64;
266}
267
268/// What a probe result means and how precisely it locates.
269#[derive(Copy, Clone, Debug, PartialEq, Eq)]
270pub enum IndexExactness {
271    /// True bits are exactly the matching rows, so the probe may serve `filter_evaluation`
272    /// directly and skip decoding the data child for that conjunct.
273    Exact,
274    /// False bits are proven non-matching; true bits are "maybe". Serves `pruning_evaluation`
275    /// only, and the real predicate re-checks the survivors.
276    Superset,
277}
278
279/// Where an index located its matches, in the data child's row space.
280///
281/// Both variants are roaring bitmaps: postings are naturally set-like, intersect and union
282/// cheaply, and expand into a [`Mask`] by walking runs in sorted order.
283///
284/// Roaring bitmaps are `u32`-keyed, which caps a single layout at `u32::MAX` rows. That is far
285/// above any practical Vortex file, and [`RowLocator::mask_for`] errors rather than truncating if
286/// it is ever exceeded.
287#[derive(Clone, Debug)]
288pub enum RowLocator {
289    /// Row positions local to the data child.
290    Rows(RoaringBitmap),
291    /// Ids of fixed `block_len`-row blocks, expanded by broadcasting each block's bit across its
292    /// rows — the same shape as the zoned reader's per-zone expansion.
293    Blocks { block_len: u64, ids: RoaringBitmap },
294}
295
296impl RowLocator {
297    /// An empty locator: nothing matches, so everything prunes.
298    pub fn empty_rows() -> Self {
299        RowLocator::Rows(RoaringBitmap::new())
300    }
301
302    /// Expand this locator into a mask covering `row_range` of the data child.
303    ///
304    /// The returned mask has length `row_range.len()` and is *not* intersected with any input
305    /// mask; callers do that.
306    pub fn mask_for(&self, row_range: &Range<u64>) -> VortexResult<Mask> {
307        let len = usize::try_from(row_range.end - row_range.start)?;
308        let mut bits = BitBufferMut::with_capacity(len);
309        self.append_to(row_range, &mut bits)?;
310        Ok(Mask::from(bits.freeze()))
311    }
312
313    /// Append this locator's bits for `row_range` to `bits`, one per row.
314    pub fn append_to(&self, row_range: &Range<u64>, bits: &mut BitBufferMut) -> VortexResult<()> {
315        match self {
316            // Walk the set bits in ascending order, emitting the false run before each one. The
317            // bitmap is sorted, so this is a single linear pass with no random access.
318            RowLocator::Rows(rows) => {
319                let start = u32::try_from(row_range.start)?;
320                let end = u32::try_from(row_range.end)?;
321                let mut pos = row_range.start;
322                for row in rows.range(start..end) {
323                    let row = u64::from(row);
324                    bits.append_n(false, usize::try_from(row - pos)?);
325                    bits.append_n(true, 1);
326                    pos = row + 1;
327                }
328                bits.append_n(false, usize::try_from(row_range.end - pos)?);
329            }
330            // Broadcast each block's bit across the rows it covers, clipped to `row_range`.
331            RowLocator::Blocks { block_len, ids } => {
332                let mut row = row_range.start;
333                while row < row_range.end {
334                    let block = row / block_len;
335                    let block_end = ((block + 1) * block_len).min(row_range.end);
336                    let hit = ids.contains(u32::try_from(block)?);
337                    bits.append_n(hit, usize::try_from(block_end - row)?);
338                    row = block_end;
339                }
340            }
341        }
342        Ok(())
343    }
344}
345
346/// How an index intends to answer one expression.
347///
348/// The probe prunes the index child's chunks with `filter`, inheriting its zone maps, then decodes
349/// the surviving chunks (each at most once per reader) and hands them to
350/// [`IndexVTable::resolve`] to answer `query`.
351pub struct IndexQueryPlan<Q> {
352    /// Whether the resulting mask is exact or a superset.
353    pub exactness: IndexExactness,
354    /// Predicate bound to the index child's dtype. Chunks it proves hold no matching index rows
355    /// are neither loaded nor decoded.
356    pub filter: BoundExpression,
357    /// What [`IndexVTable::resolve`] answers from the decoded chunks.
358    pub query: Q,
359}