Skip to main content

nodedb_columnar/reader/
segment_reader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Segment reader: decode compressed columns from a segment into typed vectors,
4//! with column projection and block predicate pushdown.
5//!
6//! # Block Wire Format
7//!
8//! Each block in a column is stored as `[compressed_len: u32 LE][compressed_data]`.
9//!
10//! The compressed_data structure depends on the column type:
11//! - **Int64/Float64/Timestamp**: `[validity_bitmap][codec_compressed_values]`
12//! - **Bool/Decimal/Uuid/Vector**: `[validity_bitmap][codec_compressed_bytes]`
13//! - **String/Bytes/Geometry**: `[validity_bitmap][offset_len: u32][compressed_offsets][compressed_data]`
14
15use crate::delete_bitmap::DeleteBitmap;
16use crate::error::ColumnarError;
17use crate::format::{HEADER_SIZE, SegmentFooter, SegmentHeader};
18use crate::predicate::ScanPredicate;
19
20use super::block_decode::{
21    append_null_fill, decode_block, empty_decoded, result_valid_len, result_valid_slice_mut,
22};
23use super::types::DecodedColumn;
24
25fn checked_slice(data: &[u8], start: usize, len: usize) -> Result<&[u8], ColumnarError> {
26    let end = start
27        .checked_add(len)
28        .ok_or_else(|| ColumnarError::Corruption {
29            segment_id: None,
30            reason: "column block range overflow".into(),
31            offset: None,
32        })?;
33    data.get(start..end).ok_or(ColumnarError::TruncatedSegment {
34        expected: end,
35        got: data.len(),
36    })
37}
38
39/// Reads and decodes columns from a segment byte buffer.
40///
41/// Supports column projection (only decode requested columns) and block
42/// predicate pushdown (skip blocks whose stats prove no match).
43pub struct SegmentReader<'a> {
44    pub(super) data: &'a [u8],
45    pub(super) footer: SegmentFooter,
46}
47
48/// An owned segment reader that holds decrypted segment bytes.
49///
50/// Used when a segment was encrypted at rest: the decrypted plaintext is owned
51/// by this struct, and `reader()` borrows from it for column decoding.
52#[derive(Debug)]
53pub struct OwnedSegmentReader {
54    /// Decrypted plaintext segment bytes.
55    plaintext: Vec<u8>,
56    footer: SegmentFooter,
57}
58
59impl OwnedSegmentReader {
60    fn from_plaintext(plaintext: Vec<u8>) -> Result<Self, ColumnarError> {
61        SegmentHeader::from_bytes(&plaintext)?;
62        let footer = SegmentFooter::from_segment_tail(&plaintext)?;
63        Ok(Self { plaintext, footer })
64    }
65
66    /// Open a segment with optional at-rest decryption.
67    ///
68    /// - `kek = None` → requires a plaintext (`NDBS`) segment; returns
69    ///   `Err(MissingKek)` if the blob starts with `SEGC`.
70    /// - `kek = Some(key)` → requires an encrypted (`SEGC`) segment; decrypts
71    ///   the blob, then parses the inner plaintext. Returns `Err(KekRequired)`
72    ///   if the blob starts with `NDBS`.
73    pub fn open_with_kek(
74        blob: &[u8],
75        kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
76    ) -> Result<Self, ColumnarError> {
77        let is_encrypted = blob.len() >= 4 && blob[0..4] == crate::encrypt::SEGC_MAGIC;
78        if is_encrypted {
79            let key = kek.ok_or(ColumnarError::MissingKek)?;
80            let plaintext = crate::encrypt::decrypt_segment(key, blob)?;
81            Self::from_plaintext(plaintext)
82        } else if kek.is_some() {
83            Err(ColumnarError::KekRequired)
84        } else {
85            Self::from_plaintext(blob.to_vec())
86        }
87    }
88
89    /// Borrow a `SegmentReader` over the owned plaintext bytes.
90    pub fn reader(&self) -> SegmentReader<'_> {
91        SegmentReader {
92            data: &self.plaintext,
93            footer: self.footer.clone(),
94        }
95    }
96
97    /// Access the segment footer.
98    pub fn footer(&self) -> &SegmentFooter {
99        &self.footer
100    }
101
102    /// Total row count in the segment.
103    pub fn row_count(&self) -> u64 {
104        self.footer.row_count
105    }
106}
107
108impl<'a> SegmentReader<'a> {
109    /// Open a plaintext segment from a byte buffer.
110    ///
111    /// Validates the `NDBS` header and footer CRC. If `data` starts with `SEGC`
112    /// (an encrypted envelope) and `kek` is `None`, returns `Err(MissingKek)`.
113    /// If `data` starts with `NDBS` (plaintext) and `kek` is `Some`, returns
114    /// `Err(KekRequired)` — refusing to load unencrypted data when encryption
115    /// is configured.
116    ///
117    /// To read an encrypted segment, use [`OwnedSegmentReader::open_with_kek`].
118    pub fn open(data: &'a [u8]) -> Result<Self, ColumnarError> {
119        if data.len() >= 4 && data[0..4] == crate::encrypt::SEGC_MAGIC {
120            return Err(ColumnarError::MissingKek);
121        }
122        SegmentHeader::from_bytes(data)?;
123        let footer = SegmentFooter::from_segment_tail(data)?;
124        Ok(Self { data, footer })
125    }
126
127    /// Access the footer metadata.
128    pub fn footer(&self) -> &SegmentFooter {
129        &self.footer
130    }
131
132    /// Total row count in the segment.
133    pub fn row_count(&self) -> u64 {
134        self.footer.row_count
135    }
136
137    /// Number of columns in the segment.
138    pub fn column_count(&self) -> usize {
139        self.footer.column_count as usize
140    }
141
142    /// Read a single column, decoding all blocks.
143    ///
144    /// `col_idx` is the column index in the footer's column metadata.
145    pub fn read_column(&self, col_idx: usize) -> Result<DecodedColumn, ColumnarError> {
146        self.read_column_filtered(col_idx, &[])
147    }
148
149    /// Read a single column with predicate pushdown.
150    ///
151    /// Blocks whose stats satisfy the predicates are skipped. For skipped
152    /// blocks, null/zero-fill rows are emitted to preserve row alignment
153    /// across projected columns.
154    pub fn read_column_filtered(
155        &self,
156        col_idx: usize,
157        predicates: &[ScanPredicate],
158    ) -> Result<DecodedColumn, ColumnarError> {
159        self.read_column_impl(col_idx, predicates, &DeleteBitmap::new())
160    }
161
162    /// Read multiple columns with shared predicate pushdown.
163    ///
164    /// All columns share the same block skip decisions so row alignment
165    /// is maintained across the result set.
166    pub fn read_columns(
167        &self,
168        col_indices: &[usize],
169        predicates: &[ScanPredicate],
170    ) -> Result<Vec<DecodedColumn>, ColumnarError> {
171        col_indices
172            .iter()
173            .map(|&idx| self.read_column_filtered(idx, predicates))
174            .collect()
175    }
176
177    /// Read a column with both predicate pushdown and delete bitmap masking.
178    ///
179    /// Deleted rows have their validity set to false in the output.
180    /// Fully deleted blocks are skipped entirely (no decompression).
181    pub fn read_column_with_deletes(
182        &self,
183        col_idx: usize,
184        predicates: &[ScanPredicate],
185        deletes: &DeleteBitmap,
186    ) -> Result<DecodedColumn, ColumnarError> {
187        self.read_column_impl(col_idx, predicates, deletes)
188    }
189
190    /// Read multiple columns with predicate pushdown and delete bitmap.
191    pub fn read_columns_with_deletes(
192        &self,
193        col_indices: &[usize],
194        predicates: &[ScanPredicate],
195        deletes: &DeleteBitmap,
196    ) -> Result<Vec<DecodedColumn>, ColumnarError> {
197        col_indices
198            .iter()
199            .map(|&idx| self.read_column_with_deletes(idx, predicates, deletes))
200            .collect()
201    }
202
203    /// Shared implementation for column reading with predicate pushdown and
204    /// optional delete bitmap masking.
205    fn read_column_impl(
206        &self,
207        col_idx: usize,
208        predicates: &[ScanPredicate],
209        deletes: &DeleteBitmap,
210    ) -> Result<DecodedColumn, ColumnarError> {
211        if col_idx >= self.footer.columns.len() {
212            return Err(ColumnarError::ColumnOutOfRange {
213                index: col_idx,
214                count: self.footer.columns.len(),
215            });
216        }
217
218        let col_meta = &self.footer.columns[col_idx];
219        let my_preds: Vec<&ScanPredicate> =
220            predicates.iter().filter(|p| p.col_idx == col_idx).collect();
221
222        let col_offset =
223            usize::try_from(col_meta.offset).map_err(|_| ColumnarError::Corruption {
224                segment_id: None,
225                reason: "column offset does not fit usize".into(),
226                offset: None,
227            })?;
228        let col_start =
229            HEADER_SIZE
230                .checked_add(col_offset)
231                .ok_or_else(|| ColumnarError::Corruption {
232                    segment_id: None,
233                    reason: "column offset range overflow".into(),
234                    offset: None,
235                })?;
236        if col_start > self.data.len() {
237            return Err(ColumnarError::TruncatedSegment {
238                expected: col_start,
239                got: self.data.len(),
240            });
241        }
242        let mut cursor = col_start;
243        let mut result = empty_decoded(col_meta.layout);
244        let mut global_row: u32 = 0;
245
246        for block_stat in &col_meta.block_stats {
247            let block_row_count = block_stat.row_count;
248
249            let header_end = cursor
250                .checked_add(4)
251                .ok_or_else(|| ColumnarError::Corruption {
252                    segment_id: None,
253                    reason: "column block header range overflow".into(),
254                    offset: None,
255                })?;
256            let header =
257                self.data
258                    .get(cursor..header_end)
259                    .ok_or(ColumnarError::TruncatedSegment {
260                        expected: header_end,
261                        got: self.data.len(),
262                    })?;
263            let block_len = usize::try_from(u32::from_le_bytes([
264                header[0], header[1], header[2], header[3],
265            ]))
266            .map_err(|_| ColumnarError::Corruption {
267                segment_id: None,
268                reason: "column block length does not fit usize".into(),
269                offset: None,
270            })?;
271            let block_data = checked_slice(self.data, header_end, block_len)?;
272            cursor =
273                header_end
274                    .checked_add(block_len)
275                    .ok_or_else(|| ColumnarError::Corruption {
276                        segment_id: None,
277                        reason: "column block range overflow".into(),
278                        offset: None,
279                    })?;
280
281            // Skip via predicate pushdown.
282            let pred_skip = my_preds.iter().any(|p| p.can_skip_block(block_stat));
283
284            // Skip if entire block is deleted.
285            let delete_skip =
286                !deletes.is_empty() && deletes.is_block_fully_deleted(global_row, block_row_count);
287
288            if pred_skip || delete_skip {
289                append_null_fill(&mut result, block_row_count as usize);
290                global_row += block_row_count;
291                continue;
292            }
293
294            // Decode the block.
295            let pre_len = result_valid_len(&result);
296            decode_block(
297                &mut result,
298                block_data,
299                col_meta.layout,
300                col_meta.codec,
301                block_row_count as usize,
302                col_meta.dictionary.as_deref(),
303            )?;
304
305            // Apply delete bitmap to the newly decoded rows.
306            if !deletes.is_empty() {
307                let valid_slice = result_valid_slice_mut(&mut result, pre_len);
308                deletes.apply_to_validity(valid_slice, global_row);
309            }
310
311            global_row += block_row_count;
312        }
313
314        Ok(result)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::checked_slice;
321    use crate::error::ColumnarError;
322
323    #[test]
324    fn block_range_rejects_truncated_and_overflowed_lengths() {
325        assert!(matches!(
326            checked_slice(&[0; 4], 2, 4),
327            Err(ColumnarError::TruncatedSegment {
328                expected: 6,
329                got: 4
330            })
331        ));
332        assert!(matches!(
333            checked_slice(&[], usize::MAX, 1),
334            Err(ColumnarError::Corruption { .. })
335        ));
336    }
337}