Skip to main content

nodedb_array/codec/
tile_decode.rs

1// SPDX-License-Identifier: Apache-2.0
2
3// Structural sparse-tile decoder — symmetric to tile_encode.
4//
5// Validates the version byte, dispatches on CodecTag, and reconstructs
6// a SparseTile from the structural payload. Legacy (v3) msgpack tiles are
7// NOT handled here; the segment reader peeks the tag and dispatches to
8// zerompk for those.
9
10use crate::codec::column_codec::{
11    decode_attr_col, decode_row_kinds, decode_surrogates, decode_timestamps_col,
12};
13use crate::codec::coord_rle::decode_coord_axis_rle;
14use crate::codec::limits::{
15    MAX_ATTRS_PER_TILE, MAX_AXES_PER_TILE, MAX_CELLS_PER_TILE, check_decoded_size, checked_capacity,
16};
17use crate::codec::tag::{CodecTag, peek_tag};
18use crate::error::{ArrayError, ArrayResult};
19use crate::tile::mbr::TileMBR;
20use crate::tile::sparse_tile::{RowKind, SparseTile};
21
22const SUPPORTED_PAYLOAD_VERSION: u8 = 1;
23
24fn read_framed<'a>(data: &'a [u8], pos: &mut usize) -> ArrayResult<&'a [u8]> {
25    if *pos + 4 > data.len() {
26        return Err(ArrayError::SegmentCorruption {
27            detail: "framed block: truncated length".into(),
28        });
29    }
30    let len = u32::from_le_bytes(
31        data[*pos..*pos + 4]
32            .try_into()
33            .expect("invariant: bounds-checked above (*pos + 4 <= data.len())"),
34    ) as usize;
35    *pos += 4;
36    if *pos + len > data.len() {
37        return Err(ArrayError::SegmentCorruption {
38            detail: format!(
39                "framed block: body truncated (need {len}, have {})",
40                data.len() - *pos
41            ),
42        });
43    }
44    let slice = &data[*pos..*pos + len];
45    *pos += len;
46    Ok(slice)
47}
48
49/// Decode a tile payload previously written by `encode_sparse_tile`.
50///
51/// The `payload` slice must start at the tag byte (i.e. after BlockFraming
52/// has been unwrapped by the segment reader).
53pub fn decode_sparse_tile(payload: &[u8]) -> ArrayResult<SparseTile> {
54    if payload.len() < 2 {
55        return Err(ArrayError::SegmentCorruption {
56            detail: "sparse tile payload too short".into(),
57        });
58    }
59
60    let tag_result = peek_tag(payload).ok_or_else(|| {
61        // peek_tag returns None for both legacy msgpack and unknown bytes.
62        // The reader should never call us with a legacy payload.
63        ArrayError::SegmentCorruption {
64            detail: format!(
65                "decode_sparse_tile called with legacy or unknown tag byte: {:#04x}",
66                payload[0]
67            ),
68        }
69    })?;
70
71    let version = payload[1];
72    if version != SUPPORTED_PAYLOAD_VERSION {
73        return Err(ArrayError::SegmentCorruption {
74            detail: format!("unsupported tile payload version: {version}"),
75        });
76    }
77
78    match tag_result {
79        CodecTag::Raw => decode_raw(&payload[2..]),
80        CodecTag::Structural => decode_structural(&payload[2..]),
81    }
82}
83
84fn decode_raw(body: &[u8]) -> ArrayResult<SparseTile> {
85    let tile: SparseTile =
86        zerompk::from_msgpack(body).map_err(|e| ArrayError::SegmentCorruption {
87            detail: format!("raw tile decode: {e}"),
88        })?;
89    tile.check_stored_identities()?;
90    Ok(tile)
91}
92
93fn decode_structural(body: &[u8]) -> ArrayResult<SparseTile> {
94    let mut pos = 0;
95
96    if pos + 8 > body.len() {
97        return Err(ArrayError::SegmentCorruption {
98            detail: "structural tile: truncated counts".into(),
99        });
100    }
101    let cell_count = u32::from_le_bytes(
102        body[pos..pos + 4]
103            .try_into()
104            .expect("invariant: bounds-checked above (pos + 8 <= body.len())"),
105    ) as usize;
106    pos += 4;
107    check_decoded_size(cell_count, MAX_CELLS_PER_TILE, "cell_count")?;
108    let axis_count = u32::from_le_bytes(
109        body[pos..pos + 4]
110            .try_into()
111            .expect("invariant: bounds-checked above (pos + 4 <= body.len())"),
112    ) as usize;
113    pos += 4;
114    let axis_capacity = checked_capacity(
115        axis_count,
116        MAX_AXES_PER_TILE,
117        body.len() - pos,
118        4,
119        "axis_count",
120    )?;
121
122    // Coordinate axes.
123    let mut dim_dicts = Vec::with_capacity(axis_capacity);
124    for _ in 0..axis_count {
125        let axis_bytes = read_framed(body, &mut pos)?;
126        let mut inner_pos = 0;
127        let dict = decode_coord_axis_rle(axis_bytes, &mut inner_pos)?;
128        dim_dicts.push(dict);
129    }
130
131    // Surrogates: live rows only, in row order.
132    let surr_bytes = read_framed(body, &mut pos)?;
133    let live_surrogates = decode_surrogates(surr_bytes)?;
134
135    // Row kinds.
136    let rk_bytes = read_framed(body, &mut pos)?;
137    let row_kinds = decode_row_kinds(rk_bytes)?;
138
139    // system_from_ms placeholder (not stored in SparseTile, just skip).
140    let _sys_bytes = read_framed(body, &mut pos)?;
141
142    // valid_from_ms.
143    let vf_bytes = read_framed(body, &mut pos)?;
144    let valid_from_ms = decode_timestamps_col(vf_bytes)?;
145
146    // valid_until_ms.
147    let vu_bytes = read_framed(body, &mut pos)?;
148    let valid_until_ms = decode_timestamps_col(vu_bytes)?;
149
150    // Attr columns.
151    if pos + 4 > body.len() {
152        return Err(ArrayError::SegmentCorruption {
153            detail: "structural tile: truncated attr count".into(),
154        });
155    }
156    let attr_count = u32::from_le_bytes(
157        body[pos..pos + 4]
158            .try_into()
159            .expect("invariant: bounds-checked above (pos + 4 <= body.len())"),
160    ) as usize;
161    pos += 4;
162    let attr_capacity = checked_capacity(
163        attr_count,
164        MAX_ATTRS_PER_TILE,
165        body.len() - pos,
166        4,
167        "attr_count",
168    )?;
169
170    let mut attr_cols = Vec::with_capacity(attr_capacity);
171    for _ in 0..attr_count {
172        let col_bytes = read_framed(body, &mut pos)?;
173        let col = decode_attr_col(col_bytes)?;
174        attr_cols.push(col);
175    }
176
177    // Reconstruct MBR from decoded data — we don't persist the MBR inside the
178    // structural payload (it lives in the footer TileEntry instead).
179    // Use a zero-dimension MBR so the tile can be used for reads; compaction
180    // re-derives the full MBR if needed.
181    let mbr = TileMBR::new(axis_count, attr_count);
182
183    // Validate sizes match cell_count.
184    if row_kinds.len() != cell_count {
185        return Err(ArrayError::SegmentCorruption {
186            detail: format!(
187                "structural tile: row kind count {kinds} != cell_count {cell_count}",
188                kinds = row_kinds.len()
189            ),
190        });
191    }
192    let surrogates = spread_live_surrogates(&row_kinds, live_surrogates)?;
193
194    let tile = SparseTile {
195        dim_dicts,
196        attr_cols,
197        surrogates,
198        valid_from_ms,
199        valid_until_ms,
200        row_kinds,
201        mbr,
202    };
203    tile.check_stored_identities()?;
204    Ok(tile)
205}
206
207/// Place the live-row surrogate column back onto every row: a live row takes
208/// the next surrogate, a tombstone or erasure row holds none. The column must
209/// hold exactly one surrogate per live row.
210fn spread_live_surrogates(
211    row_kinds: &[u8],
212    live_surrogates: Vec<nodedb_types::Surrogate>,
213) -> ArrayResult<Vec<Option<nodedb_types::Surrogate>>> {
214    let live_count = live_surrogates.len();
215    let mut live = live_surrogates.into_iter();
216    let mut surrogates = Vec::with_capacity(row_kinds.len());
217    for (row, &kind) in row_kinds.iter().enumerate() {
218        if RowKind::from_u8(kind)? == RowKind::Live {
219            let surrogate = live.next().ok_or_else(|| ArrayError::SegmentCorruption {
220                detail: format!(
221                    "structural tile: live row {row} has no surrogate ({live_count} stored)"
222                ),
223            })?;
224            surrogates.push(Some(surrogate));
225        } else {
226            surrogates.push(None);
227        }
228    }
229    if live.next().is_some() {
230        return Err(ArrayError::SegmentCorruption {
231            detail: format!("structural tile: {live_count} surrogates stored for fewer live rows"),
232        });
233    }
234    Ok(surrogates)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::codec::tile_encode::encode_sparse_tile;
241    use crate::schema::ArraySchemaBuilder;
242    use crate::schema::attr_spec::{AttrSpec, AttrType};
243    use crate::schema::dim_spec::{DimSpec, DimType};
244    use crate::tile::sparse_tile::{RowKind, SparseRow, SparseTileBuilder};
245    use crate::types::cell_value::value::CellValue;
246    use crate::types::coord::value::CoordValue;
247    use crate::types::domain::{Domain, DomainBound};
248    use nodedb_types::{OPEN_UPPER, Surrogate};
249
250    fn schema() -> crate::schema::ArraySchema {
251        ArraySchemaBuilder::new("t")
252            .dim(DimSpec::new(
253                "x",
254                DimType::Int64,
255                Domain::new(DomainBound::Int64(0), DomainBound::Int64(1_000_000)),
256            ))
257            .attr(AttrSpec::new("v", AttrType::Int64, true))
258            .tile_extents(vec![1000])
259            .build()
260            .unwrap()
261    }
262
263    fn make_tile(s: &crate::schema::ArraySchema, n: usize) -> SparseTile {
264        let mut b = SparseTileBuilder::new(s);
265        for i in 0..n {
266            b.push_row(SparseRow {
267                coord: &[CoordValue::Int64(i as i64)],
268                attrs: &[CellValue::Int64(i as i64 * 2)],
269                surrogate: Some(Surrogate::new(i as u32 + 1)),
270                valid_from_ms: i as i64,
271                valid_until_ms: OPEN_UPPER,
272                kind: RowKind::Live,
273            })
274            .unwrap();
275        }
276        b.build()
277    }
278
279    fn roundtrip(tile: &SparseTile) -> SparseTile {
280        let mut buf = Vec::new();
281        encode_sparse_tile(tile, &mut buf).unwrap();
282        decode_sparse_tile(&buf).unwrap()
283    }
284
285    #[test]
286    fn empty_tile_roundtrip() {
287        let s = schema();
288        let tile = SparseTile::empty(&s);
289        let out = roundtrip(&tile);
290        assert_eq!(out.surrogates, tile.surrogates);
291        assert_eq!(out.row_kinds, tile.row_kinds);
292    }
293
294    #[test]
295    fn small_tile_roundtrip() {
296        let s = schema();
297        let tile = make_tile(&s, 4);
298        let out = roundtrip(&tile);
299        assert_eq!(out.valid_from_ms, tile.valid_from_ms);
300        assert_eq!(out.attr_cols, tile.attr_cols);
301    }
302
303    #[test]
304    fn structural_tile_roundtrip() {
305        let s = schema();
306        let tile = make_tile(&s, 50);
307        let out = roundtrip(&tile);
308        assert_eq!(out.surrogates.len(), tile.surrogates.len());
309        assert_eq!(out.valid_from_ms, tile.valid_from_ms);
310        assert_eq!(out.valid_until_ms, tile.valid_until_ms);
311        assert_eq!(out.attr_cols, tile.attr_cols);
312        assert_eq!(out.row_kinds, tile.row_kinds);
313    }
314
315    #[test]
316    fn one_thousand_cells_roundtrip() {
317        let s = schema();
318        let tile = make_tile(&s, 1000);
319        let out = roundtrip(&tile);
320        assert_eq!(out.surrogates.len(), 1000);
321        assert_eq!(out.dim_dicts[0].indices, tile.dim_dicts[0].indices);
322    }
323
324    #[test]
325    fn tombstone_rows_roundtrip() {
326        let s = schema();
327        let mut b = SparseTileBuilder::new(&s);
328        for i in 0..20 {
329            b.push_row(SparseRow {
330                coord: &[CoordValue::Int64(i)],
331                attrs: &[CellValue::Int64(i)],
332                surrogate: Some(Surrogate::new(i as u32 + 1)),
333                valid_from_ms: 0,
334                valid_until_ms: OPEN_UPPER,
335                kind: RowKind::Live,
336            })
337            .unwrap();
338        }
339        b.push_row(SparseRow {
340            coord: &[CoordValue::Int64(99)],
341            attrs: &[],
342            surrogate: None,
343            valid_from_ms: 0,
344            valid_until_ms: OPEN_UPPER,
345            kind: RowKind::Tombstone,
346        })
347        .unwrap();
348        let tile = b.build();
349        let out = roundtrip(&tile);
350        assert_eq!(out.row_kinds, tile.row_kinds);
351        assert_eq!(out.surrogates, tile.surrogates);
352        assert_eq!(out.surrogates[20], None);
353    }
354
355    /// A structural payload whose live-row surrogate column is one short is
356    /// refused: a live row never decodes without its identity.
357    #[test]
358    fn a_live_row_without_a_stored_surrogate_is_refused() {
359        let kinds = [
360            RowKind::Live.as_u8(),
361            RowKind::Tombstone.as_u8(),
362            RowKind::Live.as_u8(),
363        ];
364        assert!(spread_live_surrogates(&kinds, vec![Surrogate::new(1)]).is_err());
365        assert!(
366            spread_live_surrogates(
367                &kinds,
368                vec![Surrogate::new(1), Surrogate::new(2), Surrogate::new(3)]
369            )
370            .is_err(),
371            "a surrogate left over after the last live row is refused"
372        );
373        assert_eq!(
374            spread_live_surrogates(&kinds, vec![Surrogate::new(1), Surrogate::new(2)]).unwrap(),
375            vec![Some(Surrogate::new(1)), None, Some(Surrogate::new(2))]
376        );
377    }
378
379    #[test]
380    fn huge_axis_count_in_tiny_structural_payload_is_rejected_before_allocation() {
381        let mut body = Vec::new();
382        body.extend_from_slice(&0u32.to_le_bytes());
383        body.extend_from_slice(&(MAX_AXES_PER_TILE as u32).to_le_bytes());
384        let error = decode_structural(&body).unwrap_err();
385        assert!(matches!(error, ArrayError::SegmentCorruption { .. }));
386    }
387
388    #[test]
389    fn invalid_version_returns_error() {
390        let s = schema();
391        let tile = make_tile(&s, 20);
392        let mut buf = Vec::new();
393        encode_sparse_tile(&tile, &mut buf).unwrap();
394        // Corrupt the version byte.
395        buf[1] = 99;
396        let err = decode_sparse_tile(&buf).unwrap_err();
397        assert!(matches!(err, ArrayError::SegmentCorruption { .. }));
398    }
399
400    #[test]
401    fn valid_time_variants_roundtrip() {
402        let s = schema();
403        let mut b = SparseTileBuilder::new(&s);
404        b.push_row(SparseRow {
405            coord: &[CoordValue::Int64(1)],
406            attrs: &[CellValue::Int64(10)],
407            surrogate: Some(Surrogate::new(1)),
408            valid_from_ms: 100,
409            valid_until_ms: 500,
410            kind: RowKind::Live,
411        })
412        .unwrap();
413        b.push_row(SparseRow {
414            coord: &[CoordValue::Int64(2)],
415            attrs: &[CellValue::Int64(20)],
416            surrogate: Some(Surrogate::new(2)),
417            valid_from_ms: 200,
418            valid_until_ms: OPEN_UPPER,
419            kind: RowKind::Live,
420        })
421        .unwrap();
422        // need >=8 to get Structural
423        for i in 3..20 {
424            b.push_row(SparseRow {
425                coord: &[CoordValue::Int64(i)],
426                attrs: &[CellValue::Int64(i)],
427                surrogate: Some(Surrogate::new(i as u32)),
428                valid_from_ms: i * 10,
429                valid_until_ms: OPEN_UPPER,
430                kind: RowKind::Live,
431            })
432            .unwrap();
433        }
434        let tile = b.build();
435        let out = roundtrip(&tile);
436        assert_eq!(out.valid_from_ms[0], 100);
437        assert_eq!(out.valid_until_ms[0], 500);
438        assert_eq!(out.valid_until_ms[1], OPEN_UPPER);
439    }
440}