Skip to main content

nodedb_lite/engine/columnar/store/
compact.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Segment compaction and bitemporal tombstone purging.
4
5use super::segments::{
6    columnar_err_to_lite, load_segment_bytes, remove_segment_bytes, store_segment_bytes,
7};
8use super::state::ColumnarEngine;
9use crate::error::LiteError;
10use crate::runtime::now_millis_i64;
11use crate::storage::engine::StorageEngine;
12use nodedb_columnar::delete_bitmap::DeleteBitmap;
13use nodedb_types::Namespace;
14use nodedb_types::columnar::{ColumnarProfile, ColumnarSchema};
15use std::collections::{HashMap, HashSet};
16
17impl<S: StorageEngine> ColumnarEngine<S> {
18    /// Check if any segments need compaction and run it.
19    pub async fn try_compact_collection(&self, collection: &str) -> Result<bool, LiteError> {
20        let state_arc = self.lookup(collection)?;
21
22        // Snapshot the data we need, plus capture per-segment delete bitmaps.
23        struct Snapshot {
24            schema: ColumnarSchema,
25            profile_tag: u8,
26            to_compact: Vec<u32>,
27            bitmaps: HashMap<u32, DeleteBitmap>,
28        }
29
30        let snap = {
31            let s = Self::lock_state(&state_arc)?;
32            let mut to_compact = Vec::new();
33            for seg_meta in &s.segments {
34                // Skip tombstoned segments — their physical file is already gone.
35                if seg_meta.fully_deleted_at_ms.is_some() {
36                    continue;
37                }
38                if let Some(bitmap) = s.mutation.delete_bitmap(seg_meta.segment_id as u64)
39                    && bitmap.should_compact(seg_meta.row_count, 0.2)
40                {
41                    to_compact.push(seg_meta.segment_id);
42                }
43            }
44            if to_compact.is_empty() {
45                return Ok(false);
46            }
47            let schema = s.mutation.schema().clone();
48            let profile_tag = match &s.profile {
49                ColumnarProfile::Plain => 0,
50                ColumnarProfile::Timeseries { .. } => 1,
51                ColumnarProfile::Spatial { .. } => 2,
52            };
53            let mut bitmaps = HashMap::new();
54            for &seg_id in &to_compact {
55                if let Some(b) = s.mutation.delete_bitmap(seg_id as u64) {
56                    bitmaps.insert(seg_id, b.clone());
57                }
58            }
59            Snapshot {
60                schema,
61                profile_tag,
62                to_compact,
63                bitmaps,
64            }
65        };
66
67        for seg_id in &snap.to_compact {
68            let seg_bytes = match load_segment_bytes(&*self.storage, collection, *seg_id).await? {
69                Some(b) => b,
70                None => continue,
71            };
72
73            let empty_bitmap = DeleteBitmap::new();
74            let bitmap = snap.bitmaps.get(seg_id).unwrap_or(&empty_bitmap);
75
76            let result = nodedb_columnar::compaction::compact_segment(
77                &seg_bytes,
78                bitmap,
79                &snap.schema,
80                snap.profile_tag,
81                &self.memory,
82                None,
83            )
84            .map_err(columnar_err_to_lite)?;
85
86            if let Some(new_seg_bytes) = result.segment {
87                store_segment_bytes(&*self.storage, collection, *seg_id, &new_seg_bytes).await?;
88
89                // Update row count under the inner lock (scoped so the guard
90                // never crosses the await below — clippy is strict).
91                {
92                    let mut s = Self::lock_state(&state_arc)?;
93                    if let Some(meta) = s.segments.iter_mut().find(|m| m.segment_id == *seg_id) {
94                        meta.row_count = result.live_rows as u64;
95                    }
96                }
97
98                let del_key = format!("{collection}:del:{seg_id}");
99                self.storage
100                    .delete(Namespace::Columnar, del_key.as_bytes())
101                    .await?;
102            } else {
103                // All rows deleted. For bitemporal collections, tombstone the
104                // segment meta (retain the entry with fully_deleted_at_ms set)
105                // so `purge_bitemporal_before` can physically remove it later.
106                // For non-bitemporal collections, remove immediately.
107                let is_bitemporal = {
108                    let s = Self::lock_state(&state_arc)?;
109                    s.bitemporal
110                };
111
112                remove_segment_bytes(&*self.storage, collection, *seg_id).await?;
113                let del_key = format!("{collection}:del:{seg_id}");
114                self.storage
115                    .delete(Namespace::Columnar, del_key.as_bytes())
116                    .await?;
117
118                {
119                    let mut s = Self::lock_state(&state_arc)?;
120                    if is_bitemporal {
121                        // Mark as fully deleted instead of removing from the list.
122                        if let Some(meta) = s.segments.iter_mut().find(|m| m.segment_id == *seg_id)
123                        {
124                            meta.row_count = 0;
125                            meta.fully_deleted_at_ms = Some(now_millis_i64());
126                        }
127                    } else {
128                        s.segments.retain(|m| m.segment_id != *seg_id);
129                    }
130                }
131            }
132        }
133
134        // Persist updated metadata.
135        let meta_bytes = {
136            let s = Self::lock_state(&state_arc)?;
137            zerompk::to_msgpack_vec(&s.segments).map_err(|e| LiteError::Serialization {
138                detail: e.to_string(),
139            })?
140        };
141        let meta_key = format!("{collection}:meta");
142        self.storage
143            .put(Namespace::Columnar, meta_key.as_bytes(), &meta_bytes)
144            .await?;
145
146        Ok(true)
147    }
148
149    /// Purge fully-deleted segment tombstones for a bitemporal collection where
150    /// `fully_deleted_at_ms < cutoff_ms`. Non-bitemporal collections always
151    /// return `rows_affected: 0` — they have no tombstones.
152    ///
153    /// Returns the number of tombstoned segment entries removed.
154    pub async fn purge_bitemporal_before(
155        &self,
156        collection: &str,
157        cutoff_ms: i64,
158    ) -> Result<u64, LiteError> {
159        let state_arc = self.lookup(collection)?;
160
161        let (is_bitemporal, to_purge): (bool, Vec<u32>) = {
162            let s = Self::lock_state(&state_arc)?;
163            let purge: Vec<u32> = s
164                .segments
165                .iter()
166                .filter(|m| m.fully_deleted_at_ms.is_some_and(|t| t < cutoff_ms))
167                .map(|m| m.segment_id)
168                .collect();
169            (s.bitemporal, purge)
170        };
171
172        if !is_bitemporal {
173            return Ok(0);
174        }
175
176        if to_purge.is_empty() {
177            return Ok(0);
178        }
179
180        let purged_ids: HashSet<u32> = to_purge.iter().copied().collect();
181
182        // Remove purged segment IDs from the in-memory list.
183        {
184            let mut s = Self::lock_state(&state_arc)?;
185            s.segments.retain(|m| !purged_ids.contains(&m.segment_id));
186        }
187
188        // Persist the updated segment metadata list.
189        let meta_bytes = {
190            let s = Self::lock_state(&state_arc)?;
191            zerompk::to_msgpack_vec(&s.segments).map_err(|e| LiteError::Serialization {
192                detail: e.to_string(),
193            })?
194        };
195        let meta_key = format!("{collection}:meta");
196        self.storage
197            .put(Namespace::Columnar, meta_key.as_bytes(), &meta_bytes)
198            .await?;
199
200        Ok(to_purge.len() as u64)
201    }
202}