1use 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
22pub struct RetentionMergeResult {
26 pub ceiling_tile: Option<SparseTile>,
31 pub keep_inhorizon: Vec<TileId>,
33 pub dropped_tile_ids: Vec<TileId>,
37 pub cells_carried_forward: usize,
39}
40
41pub 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
54pub struct DecodedRow {
58 pub coord_key: Vec<u8>,
59 pub coord: Vec<CoordValue>,
60 pub kind: RowKind,
61 pub payload: Option<CellPayload>,
63}
64
65pub 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 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 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
157pub 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 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 let keep_inhorizon: Vec<TileId> = inside.iter().map(|e| e.tile_id).collect();
197
198 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 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 }
224
225 outside.sort_by_key(|e| std::cmp::Reverse(e.tile_id.system_from_ms));
228
229 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 if inhorizon_coords.contains(&trow.coord_key) {
240 continue;
241 }
242 ceiling
244 .entry(trow.coord_key)
245 .or_insert((trow.coord, trow.kind, trow.payload));
246 }
247 }
248 }
249
250 let dropped_tile_ids: Vec<TileId> = outside.iter().map(|e| e.tile_id).collect();
253
254 let mut builder = SparseTileBuilder::new(schema);
258 let mut cells_carried_forward: usize = 0;
259
260 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 }
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
307fn 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#[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 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 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 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 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 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 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]
436 fn cell_preservation_across_sparse_writes() {
437 let s = schema();
438 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]
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]
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]
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]
536 fn tombstone_below_with_inhorizon_live_succeeds() {
537 let s = schema();
538 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 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]
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]
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}