Skip to main content

nodedb_array/query/
retention.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Cell-level retention merger for bitemporal tile compaction.
4//!
5//! [`merge_for_retention`] replaces the tile-granularity
6//! `partition_by_retention` for compaction paths that must honour the sparse
7//! tile model: because tile-versions are SPARSE (each version only stores the
8//! cells that were written at that `system_from_ms`), the retention ceiling
9//! must be computed per cell coordinate, not per tile.
10
11use std::collections::{HashMap, HashSet};
12
13use crate::error::{ArrayError, ArrayResult};
14use crate::schema::ArraySchema;
15use crate::segment::TileEntry;
16use crate::segment::reader::{SegmentReader, TilePayload};
17use crate::tile::cell_payload::CellPayload;
18use crate::tile::sparse_tile::{RowKind, SparseRow, SparseTile, SparseTileBuilder};
19use crate::types::TileId;
20use crate::types::coord::value::CoordValue;
21
22// ── Result type ─────────────────────────────────────────────────────────────
23
24/// Result of [`merge_for_retention`].
25pub struct RetentionMergeResult {
26    /// Synthetic ceiling tile materialising all cells live AS OF the horizon.
27    ///
28    /// `None` when there are zero cells to carry forward (everything erased
29    /// or no out-of-horizon versions existed).
30    pub ceiling_tile: Option<SparseTile>,
31    /// TileIds of in-horizon tile-versions that pass through unchanged.
32    pub keep_inhorizon: Vec<TileId>,
33    /// TileIds of out-of-horizon tile-versions whose cells were merged into
34    /// the ceiling tile (or dropped via GDPR) and should be removed from the
35    /// segment.
36    pub dropped_tile_ids: Vec<TileId>,
37    /// Telemetry: number of distinct cells materialised into the ceiling.
38    pub cells_carried_forward: usize,
39}
40
41// ── Coord key helpers ────────────────────────────────────────────────────────
42
43/// Encode a coordinate vector into a stable byte key for HashMap / HashSet use.
44///
45/// Uses the same zerompk encoding as the write path so keys are comparable
46/// across tile-versions.
47pub fn encode_coord_key(coord: &[CoordValue]) -> ArrayResult<Vec<u8>> {
48    let owned = coord.to_vec();
49    zerompk::to_msgpack_vec(&owned).map_err(|e| ArrayError::SegmentCorruption {
50        detail: format!("encode_coord_key: {e}"),
51    })
52}
53
54// ── Row iteration helper ─────────────────────────────────────────────────────
55
56/// Decoded row from a [`SparseTile`].
57pub struct DecodedRow {
58    pub coord_key: Vec<u8>,
59    pub coord: Vec<CoordValue>,
60    pub kind: RowKind,
61    /// `Some` for `Live` rows; `None` for `Tombstone` / `GdprErased`.
62    pub payload: Option<CellPayload>,
63}
64
65/// Iterate every row in a [`SparseTile`], decoding coords and payload.
66///
67/// Rows are returned in storage order (same as `row_count()` iteration).
68/// Attribute columns in `SparseTile` are indexed by *live-row index*, not by
69/// iteration index, so a separate live counter advances only for Live rows.
70pub fn decode_sparse_rows(tile: &SparseTile) -> ArrayResult<Vec<DecodedRow>> {
71    let n = tile.row_count();
72    let arity = tile.dim_dicts.len();
73    let mut rows = Vec::with_capacity(n);
74    let mut live_idx: usize = 0;
75
76    for row in 0..n {
77        // Decode coordinate for this row.
78        let mut coord = Vec::with_capacity(arity);
79        for dim_idx in 0..arity {
80            let dict =
81                tile.dim_dicts
82                    .get(dim_idx)
83                    .ok_or_else(|| ArrayError::SegmentCorruption {
84                        detail: format!("decode_sparse_rows: dim {dim_idx} missing"),
85                    })?;
86            let entry_idx = *dict
87                .indices
88                .get(row)
89                .ok_or_else(|| ArrayError::SegmentCorruption {
90                    detail: format!("decode_sparse_rows: row {row} index out of range"),
91                })? as usize;
92            let val = dict
93                .values
94                .get(entry_idx)
95                .ok_or_else(|| ArrayError::SegmentCorruption {
96                    detail: format!("decode_sparse_rows: dict entry {entry_idx} out of range"),
97                })?;
98            coord.push(val.clone());
99        }
100
101        let coord_key = encode_coord_key(&coord)?;
102        let kind = tile.row_kind(row)?;
103
104        let payload = match kind {
105            RowKind::Live => {
106                // Build attrs from each attr column using live_idx.
107                let attrs: Vec<_> = tile
108                    .attr_cols
109                    .iter()
110                    .map(|col| {
111                        col.get(live_idx)
112                            .cloned()
113                            .ok_or_else(|| ArrayError::SegmentCorruption {
114                                detail: format!(
115                                    "decode_sparse_rows: attr col live_idx {live_idx} out of range"
116                                ),
117                            })
118                    })
119                    .collect::<ArrayResult<Vec<_>>>()?;
120
121                let surrogate = tile.live_surrogate(row)?;
122                let valid_from_ms = tile.valid_from_ms.get(row).copied().ok_or_else(|| {
123                    ArrayError::SegmentCorruption {
124                        detail: format!("decode_sparse_rows: valid_from_ms row {row} out of range"),
125                    }
126                })?;
127                let valid_until_ms = tile.valid_until_ms.get(row).copied().ok_or_else(|| {
128                    ArrayError::SegmentCorruption {
129                        detail: format!(
130                            "decode_sparse_rows: valid_until_ms row {row} out of range"
131                        ),
132                    }
133                })?;
134
135                live_idx += 1;
136                Some(CellPayload {
137                    valid_from_ms,
138                    valid_until_ms,
139                    attrs,
140                    surrogate,
141                })
142            }
143            RowKind::Tombstone | RowKind::GdprErased => None,
144        };
145
146        rows.push(DecodedRow {
147            coord_key,
148            coord,
149            kind,
150            payload,
151        });
152    }
153
154    Ok(rows)
155}
156
157// ── Public API ───────────────────────────────────────────────────────────────
158
159/// Merge tile-versions for one `hilbert_prefix` according to the retention
160/// policy, operating at cell granularity.
161///
162/// `versions` contains all [`TileEntry`] records for a single `hilbert_prefix`
163/// (the caller is responsible for grouping). They may arrive in any order; this
164/// function sorts internally.
165///
166/// `horizon_ms` is the retention boundary expressed as an absolute
167/// `system_from_ms` timestamp: versions with `system_from_ms < horizon_ms`
168/// are outside the retention window.
169pub fn merge_for_retention(
170    versions: &[TileEntry],
171    reader: &SegmentReader<'_>,
172    schema: &ArraySchema,
173    horizon_ms: i64,
174) -> ArrayResult<RetentionMergeResult> {
175    if versions.is_empty() {
176        return Ok(RetentionMergeResult {
177            ceiling_tile: None,
178            keep_inhorizon: Vec::new(),
179            dropped_tile_ids: Vec::new(),
180            cells_carried_forward: 0,
181        });
182    }
183
184    // Partition into inside / outside by system_from_ms.
185    let mut inside: Vec<&TileEntry> = Vec::new();
186    let mut outside: Vec<&TileEntry> = Vec::new();
187    for entry in versions {
188        if entry.tile_id.system_from_ms >= horizon_ms {
189            inside.push(entry);
190        } else {
191            outside.push(entry);
192        }
193    }
194
195    // Collect TileIds for in-horizon pass-through.
196    let keep_inhorizon: Vec<TileId> = inside.iter().map(|e| e.tile_id).collect();
197
198    // If nothing is outside the horizon, there is nothing to merge.
199    if outside.is_empty() {
200        return Ok(RetentionMergeResult {
201            ceiling_tile: None,
202            keep_inhorizon,
203            dropped_tile_ids: Vec::new(),
204            cells_carried_forward: 0,
205        });
206    }
207
208    // Build the set of coord keys already covered by inside-horizon versions.
209    // Any coord present in any inside tile (regardless of RowKind) supersedes
210    // what the ceiling would contribute for that coord.
211    let mut inhorizon_coords: HashSet<Vec<u8>> = HashSet::new();
212    for entry in &inside {
213        let tile_idx = find_tile_index(reader, entry.tile_id)?;
214        let payload = reader.read_tile(tile_idx)?;
215        if let TilePayload::Sparse(ref tile) = payload {
216            for trow in decode_sparse_rows(tile)? {
217                inhorizon_coords.insert(trow.coord_key);
218            }
219        }
220        // Dense tiles inside horizon contribute no coords to the exclusion set
221        // for the ceiling (dense tiles don't participate in sparse retention
222        // merge; their coords are not relevant here).
223    }
224
225    // Walk outside-horizon versions newest → oldest, accumulating the ceiling.
226    // For each coord not yet seen, the first (newest) occurrence wins.
227    outside.sort_by_key(|e| std::cmp::Reverse(e.tile_id.system_from_ms));
228
229    // Maps coord_key → (coord, RowKind, Option<CellPayload>).
230    let mut ceiling: HashMap<Vec<u8>, (Vec<CoordValue>, RowKind, Option<CellPayload>)> =
231        HashMap::new();
232
233    for entry in &outside {
234        let tile_idx = find_tile_index(reader, entry.tile_id)?;
235        let payload = reader.read_tile(tile_idx)?;
236        if let TilePayload::Sparse(ref tile) = payload {
237            for trow in decode_sparse_rows(tile)? {
238                // Skip coords already covered by an inside-horizon version.
239                if inhorizon_coords.contains(&trow.coord_key) {
240                    continue;
241                }
242                // First occurrence (newest) wins for each coord.
243                ceiling
244                    .entry(trow.coord_key)
245                    .or_insert((trow.coord, trow.kind, trow.payload));
246            }
247        }
248    }
249
250    // Collect dropped TileIds — all outside tile-versions are superseded by
251    // the ceiling or dropped via GDPR.
252    let dropped_tile_ids: Vec<TileId> = outside.iter().map(|e| e.tile_id).collect();
253
254    // Build the synthetic ceiling SparseTile.
255    // GdprErased coords are excluded from the ceiling entirely.
256    // Tombstone and Live coords are included with their respective RowKind.
257    let mut builder = SparseTileBuilder::new(schema);
258    let mut cells_carried_forward: usize = 0;
259
260    // Deterministic output order: sort by coord_key for stable tests.
261    type CeilingRow = (Vec<u8>, Vec<CoordValue>, RowKind, Option<CellPayload>);
262    let mut ceiling_rows: Vec<CeilingRow> = ceiling
263        .into_iter()
264        .map(|(k, (coord, kind, payload))| (k, coord, kind, payload))
265        .collect();
266    ceiling_rows.sort_by(|a, b| a.0.cmp(&b.0));
267
268    for (_key, coord, kind, payload) in ceiling_rows {
269        match kind {
270            RowKind::GdprErased => {
271                // Drop entirely — GDPR erasure removes the cell from the ceiling.
272            }
273            RowKind::Tombstone => {
274                builder.push_row(SparseRow::sentinel(&coord, RowKind::Tombstone))?;
275                cells_carried_forward += 1;
276            }
277            RowKind::Live => {
278                let p = payload.ok_or_else(|| ArrayError::SegmentCorruption {
279                    detail: "Live row in ceiling has no CellPayload".into(),
280                })?;
281                builder.push_row(SparseRow::live(
282                    &coord,
283                    &p.attrs,
284                    p.surrogate,
285                    p.valid_from_ms,
286                    p.valid_until_ms,
287                ))?;
288                cells_carried_forward += 1;
289            }
290        }
291    }
292
293    let ceiling_tile = if cells_carried_forward == 0 {
294        None
295    } else {
296        Some(builder.build())
297    };
298
299    Ok(RetentionMergeResult {
300        ceiling_tile,
301        keep_inhorizon,
302        dropped_tile_ids,
303        cells_carried_forward,
304    })
305}
306
307// ── Internal helpers ─────────────────────────────────────────────────────────
308
309/// Locate the position of a tile with `tile_id` in the reader's footer.
310fn find_tile_index(reader: &SegmentReader<'_>, tile_id: TileId) -> ArrayResult<usize> {
311    reader
312        .tiles()
313        .iter()
314        .position(|e| e.tile_id == tile_id)
315        .ok_or_else(|| ArrayError::SegmentCorruption {
316            detail: format!("find_tile_index: TileId {tile_id:?} not found in segment"),
317        })
318}
319
320// ── Tests ────────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::schema::ArraySchemaBuilder;
326    use crate::schema::attr_spec::{AttrSpec, AttrType};
327    use crate::schema::dim_spec::{DimSpec, DimType};
328    use crate::segment::writer::SegmentWriter;
329    use crate::types::cell_value::value::CellValue;
330    use crate::types::coord::value::CoordValue;
331    use crate::types::domain::{Domain, DomainBound};
332    use nodedb_types::{OPEN_UPPER, Surrogate};
333
334    fn schema() -> ArraySchema {
335        ArraySchemaBuilder::new("t")
336            .dim(DimSpec::new(
337                "x",
338                DimType::Int64,
339                Domain::new(DomainBound::Int64(0), DomainBound::Int64(1000)),
340            ))
341            .attr(AttrSpec::new("v", AttrType::Int64, true))
342            .tile_extents(vec![100])
343            .build()
344            .unwrap()
345    }
346
347    /// Write a segment with the given (TileId, SparseTile) pairs (must be
348    /// strictly ascending by TileId) and return the in-memory bytes.
349    fn build_segment(pairs: Vec<(TileId, SparseTile)>) -> Vec<u8> {
350        let mut w = SegmentWriter::new(0xBEEF);
351        for (id, tile) in pairs {
352            w.append_sparse(id, &tile).unwrap();
353        }
354        w.finish(None).unwrap()
355    }
356
357    /// Build a single-row Live SparseTile at coord `x` with value `v`.
358    fn live_tile(schema: &ArraySchema, x: i64, v: i64) -> SparseTile {
359        let mut b = SparseTileBuilder::new(schema);
360        b.push_row(SparseRow {
361            coord: &[CoordValue::Int64(x)],
362            attrs: &[CellValue::Int64(v)],
363            surrogate: Some(Surrogate::new(x as u32 + 1)),
364            valid_from_ms: 0,
365            valid_until_ms: OPEN_UPPER,
366            kind: RowKind::Live,
367        })
368        .unwrap();
369        b.build()
370    }
371
372    /// Build a single-row Tombstone SparseTile at coord `x`.
373    fn tombstone_tile(schema: &ArraySchema, x: i64) -> SparseTile {
374        let mut b = SparseTileBuilder::new(schema);
375        b.push_row(SparseRow {
376            coord: &[CoordValue::Int64(x)],
377            attrs: &[],
378            surrogate: None,
379            valid_from_ms: 0,
380            valid_until_ms: OPEN_UPPER,
381            kind: RowKind::Tombstone,
382        })
383        .unwrap();
384        b.build()
385    }
386
387    /// Build a single-row GdprErased SparseTile at coord `x`.
388    fn gdpr_tile(schema: &ArraySchema, x: i64) -> SparseTile {
389        let mut b = SparseTileBuilder::new(schema);
390        b.push_row(SparseRow {
391            coord: &[CoordValue::Int64(x)],
392            attrs: &[],
393            surrogate: None,
394            valid_from_ms: 0,
395            valid_until_ms: OPEN_UPPER,
396            kind: RowKind::GdprErased,
397        })
398        .unwrap();
399        b.build()
400    }
401
402    /// Collect coord x-values from a SparseTile (single-dim schema).
403    fn ceiling_x_values(tile: &SparseTile) -> Vec<i64> {
404        let rows = decode_sparse_rows(tile).unwrap();
405        let mut xs: Vec<i64> = rows
406            .iter()
407            .map(|r| match r.coord[0] {
408                CoordValue::Int64(x) => x,
409                _ => panic!("unexpected coord type"),
410            })
411            .collect();
412        xs.sort_unstable();
413        xs
414    }
415
416    /// Collect RowKinds from ceiling tile rows, keyed by coord.
417    fn ceiling_kinds(tile: &SparseTile) -> HashMap<i64, RowKind> {
418        decode_sparse_rows(tile)
419            .unwrap()
420            .into_iter()
421            .map(|r| {
422                let x = match r.coord[0] {
423                    CoordValue::Int64(x) => x,
424                    _ => panic!("unexpected coord type"),
425                };
426                (x, r.kind)
427            })
428            .collect()
429    }
430
431    // ── Test 1 ──────────────────────────────────────────────────────────────
432
433    /// Two coords written at different system times, both outside horizon.
434    /// Both must appear in the ceiling (the classic cell-loss regression).
435    #[test]
436    fn cell_preservation_across_sparse_writes() {
437        let s = schema();
438        // T=100: writes coord x=1. T=200: writes coord x=2. Horizon=300.
439        let t100 = TileId::new(0, 100);
440        let t200 = TileId::new(0, 200);
441        let bytes = build_segment(vec![
442            (t100, live_tile(&s, 1, 10)),
443            (t200, live_tile(&s, 2, 20)),
444        ]);
445        let reader = SegmentReader::open(&bytes).unwrap();
446        let versions: Vec<TileEntry> = reader.tiles().to_vec();
447        let result = merge_for_retention(&versions, &reader, &s, 300).unwrap();
448        let ceiling = result.ceiling_tile.expect("ceiling must be non-None");
449        let xs = ceiling_x_values(&ceiling);
450        assert_eq!(xs, vec![1, 2], "both cells must survive in ceiling");
451        assert_eq!(result.cells_carried_forward, 2);
452        assert!(result.keep_inhorizon.is_empty());
453        assert_eq!(result.dropped_tile_ids.len(), 2);
454    }
455
456    // ── Test 2 ──────────────────────────────────────────────────────────────
457
458    /// All versions inside horizon → pass through unchanged, no ceiling.
459    #[test]
460    fn inhorizon_versions_pass_through_unchanged() {
461        let s = schema();
462        let t400 = TileId::new(0, 400);
463        let t500 = TileId::new(0, 500);
464        let bytes = build_segment(vec![
465            (t400, live_tile(&s, 1, 10)),
466            (t500, live_tile(&s, 1, 20)),
467        ]);
468        let reader = SegmentReader::open(&bytes).unwrap();
469        let versions: Vec<TileEntry> = reader.tiles().to_vec();
470        let result = merge_for_retention(&versions, &reader, &s, 300).unwrap();
471        assert!(result.ceiling_tile.is_none());
472        let mut keep: Vec<i64> = result
473            .keep_inhorizon
474            .iter()
475            .map(|id| id.system_from_ms)
476            .collect();
477        keep.sort_unstable();
478        assert_eq!(keep, vec![400, 500]);
479        assert!(result.dropped_tile_ids.is_empty());
480    }
481
482    // ── Test 3 ──────────────────────────────────────────────────────────────
483
484    /// Mixed: T=100 (cell A), T=200 (cell B), T=400 (cell C). Horizon=300.
485    /// Ceiling contains A+B; T=400 is in keep_inhorizon.
486    #[test]
487    fn mixed_inhorizon_and_outhorizon() {
488        let s = schema();
489        let bytes = build_segment(vec![
490            (TileId::new(0, 100), live_tile(&s, 1, 10)),
491            (TileId::new(0, 200), live_tile(&s, 2, 20)),
492            (TileId::new(0, 400), live_tile(&s, 3, 30)),
493        ]);
494        let reader = SegmentReader::open(&bytes).unwrap();
495        let versions: Vec<TileEntry> = reader.tiles().to_vec();
496        let result = merge_for_retention(&versions, &reader, &s, 300).unwrap();
497        let ceiling = result.ceiling_tile.expect("ceiling must have A and B");
498        let xs = ceiling_x_values(&ceiling);
499        assert_eq!(xs, vec![1, 2]);
500        assert_eq!(result.cells_carried_forward, 2);
501        assert_eq!(result.keep_inhorizon, vec![TileId::new(0, 400)]);
502        let mut dropped: Vec<i64> = result
503            .dropped_tile_ids
504            .iter()
505            .map(|id| id.system_from_ms)
506            .collect();
507        dropped.sort_unstable();
508        assert_eq!(dropped, vec![100, 200]);
509    }
510
511    // ── Test 4 ──────────────────────────────────────────────────────────────
512
513    /// T=100 Live(A), T=200 Tombstone(A). Horizon=300.
514    /// Ceiling contains Tombstone(A) — newest outside-horizon wins.
515    #[test]
516    fn tombstone_collapses_into_ceiling() {
517        let s = schema();
518        let bytes = build_segment(vec![
519            (TileId::new(0, 100), live_tile(&s, 1, 10)),
520            (TileId::new(0, 200), tombstone_tile(&s, 1)),
521        ]);
522        let reader = SegmentReader::open(&bytes).unwrap();
523        let versions: Vec<TileEntry> = reader.tiles().to_vec();
524        let result = merge_for_retention(&versions, &reader, &s, 300).unwrap();
525        let ceiling = result.ceiling_tile.expect("ceiling must contain tombstone");
526        let kinds = ceiling_kinds(&ceiling);
527        assert_eq!(kinds.get(&1), Some(&RowKind::Tombstone));
528        assert_eq!(result.cells_carried_forward, 1);
529    }
530
531    // ── Test 5 ──────────────────────────────────────────────────────────────
532
533    /// T=100 Tombstone(A), T=400 Live(A). Horizon=300.
534    /// Inside version T=400 has Live(A) — ceiling must NOT include A.
535    #[test]
536    fn tombstone_below_with_inhorizon_live_succeeds() {
537        let s = schema();
538        // TileIds must be strictly ascending for SegmentWriter.
539        let bytes = build_segment(vec![
540            (TileId::new(0, 100), tombstone_tile(&s, 1)),
541            (TileId::new(0, 400), live_tile(&s, 1, 99)),
542        ]);
543        let reader = SegmentReader::open(&bytes).unwrap();
544        let versions: Vec<TileEntry> = reader.tiles().to_vec();
545        let result = merge_for_retention(&versions, &reader, &s, 300).unwrap();
546        // Ceiling should be None: coord x=1 is covered by the inside version.
547        assert!(
548            result.ceiling_tile.is_none(),
549            "inside version covers x=1; ceiling must be empty"
550        );
551        assert_eq!(result.cells_carried_forward, 0);
552        assert_eq!(result.keep_inhorizon, vec![TileId::new(0, 400)]);
553    }
554
555    // ── Test 6 ──────────────────────────────────────────────────────────────
556
557    /// T=100 Live(A), T=200 GdprErased(A). Horizon=300.
558    /// GDPR erasure takes precedence: ceiling does NOT contain A.
559    #[test]
560    fn gdpr_erasure_drops_cell_outright() {
561        let s = schema();
562        let bytes = build_segment(vec![
563            (TileId::new(0, 100), live_tile(&s, 1, 10)),
564            (TileId::new(0, 200), gdpr_tile(&s, 1)),
565        ]);
566        let reader = SegmentReader::open(&bytes).unwrap();
567        let versions: Vec<TileEntry> = reader.tiles().to_vec();
568        let result = merge_for_retention(&versions, &reader, &s, 300).unwrap();
569        assert!(
570            result.ceiling_tile.is_none(),
571            "GdprErased newest version must suppress cell from ceiling"
572        );
573        assert_eq!(result.cells_carried_forward, 0);
574        assert_eq!(result.dropped_tile_ids.len(), 2);
575    }
576
577    // ── Test 7 ──────────────────────────────────────────────────────────────
578
579    /// Empty input → all result fields empty.
580    #[test]
581    fn empty_input_returns_empty() {
582        let s = schema();
583        let bytes = build_segment(vec![]);
584        let reader = SegmentReader::open(&bytes).unwrap();
585        let result = merge_for_retention(&[], &reader, &s, 300).unwrap();
586        assert!(result.ceiling_tile.is_none());
587        assert!(result.keep_inhorizon.is_empty());
588        assert!(result.dropped_tile_ids.is_empty());
589        assert_eq!(result.cells_carried_forward, 0);
590    }
591}