Skip to main content

nodedb_columnar/compaction/
segment.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Single-segment compaction: drop deleted rows from one segment, write a new one.
4
5use nodedb_mem::ScopedMemory;
6use nodedb_types::columnar::{ColumnType, ColumnarSchema};
7use nodedb_types::value::Value;
8
9use crate::delete_bitmap::DeleteBitmap;
10use crate::error::ColumnarError;
11use crate::memtable::ColumnarMemtable;
12use crate::reader::{DecodedColumn, SegmentReader, decoded_cell_value};
13use crate::writer::SegmentWriter;
14
15/// Default compaction threshold: compact when >20% of rows are deleted.
16pub const DEFAULT_DELETE_RATIO_THRESHOLD: f64 = 0.2;
17
18/// Result of a compaction operation.
19pub struct CompactionResult {
20    /// The new compacted segment bytes. `None` if all rows were deleted.
21    pub segment: Option<Vec<u8>>,
22    /// Number of live rows in the new segment.
23    pub live_rows: usize,
24    /// Number of rows removed (deleted).
25    pub removed_rows: usize,
26}
27
28/// Compact a single segment by removing deleted rows.
29///
30/// Reads the segment, skips rows marked in the delete bitmap, and writes
31/// a new segment with only live rows. Returns `None` segment if all rows
32/// were deleted.
33///
34/// When `kek` is `Some`, the output segment is wrapped in an AES-256-GCM
35/// SEGC envelope. The input segment must be plaintext (the caller is
36/// responsible for decrypting before passing to this function).
37///
38/// `memory` tracks working-buffer allocations against the bound database,
39/// tenant, and engine budget.
40pub fn compact_segment(
41    segment_data: &[u8],
42    deletes: &DeleteBitmap,
43    schema: &ColumnarSchema,
44    profile_tag: u8,
45    memory: &ScopedMemory,
46    kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
47) -> Result<CompactionResult, ColumnarError> {
48    let reader = SegmentReader::open(segment_data)?;
49    let total_rows = reader.row_count() as usize;
50    let deleted = deletes.deleted_count() as usize;
51    let live = total_rows.saturating_sub(deleted);
52
53    if live == 0 {
54        return Ok(CompactionResult {
55            segment: None,
56            live_rows: 0,
57            removed_rows: total_rows,
58        });
59    }
60
61    // Read all columns without delete masking — we'll filter manually.
62    let col_count = reader.column_count();
63    // Reserve budget for the decoded-column pointer vec (each entry is a fat pointer).
64    let _cols_guard = memory.reserve(col_count * std::mem::size_of::<usize>() * 3)?;
65    let mut decoded_cols = Vec::with_capacity(col_count);
66    for i in 0..col_count {
67        decoded_cols.push(reader.read_column(i)?);
68    }
69
70    // Build a new memtable with only live rows.
71    let mut memtable = ColumnarMemtable::new(schema);
72    let col_len = schema.columns.len();
73    let _row_guard = memory.reserve(col_len * std::mem::size_of::<usize>() * 3)?;
74    let mut row_values = Vec::with_capacity(col_len);
75
76    for row_idx in 0..total_rows {
77        if deletes.is_deleted(row_idx as u32) {
78            continue;
79        }
80
81        row_values.clear();
82        for (col_idx, decoded) in decoded_cols.iter().enumerate() {
83            let col = &schema.columns[col_idx];
84            row_values.push(carried_cell(decoded, row_idx, &col.column_type)?);
85        }
86
87        memtable.append_row(&row_values)?;
88    }
89
90    let (schema, columns, row_count) = memtable.drain();
91    let writer = SegmentWriter::new(profile_tag, memory.clone());
92    let new_segment = writer.write_segment(&schema, &columns, row_count, kek)?;
93
94    Ok(CompactionResult {
95        segment: Some(new_segment),
96        live_rows: row_count,
97        removed_rows: deleted,
98    })
99}
100
101/// One cell as the value that re-appends it to a memtable unchanged.
102///
103/// A JSON cell carries its stored MessagePack bytes, which a JSON column
104/// appends as is. Its decoded value does not always re-append: a JSON string
105/// scalar reads as `Value::String`, which a JSON column parses as JSON text.
106/// Every other cell carries its read value.
107fn carried_cell(
108    decoded: &DecodedColumn,
109    row_idx: usize,
110    declared: &ColumnType,
111) -> Result<Value, ColumnarError> {
112    let value = decoded_cell_value(decoded, row_idx, declared)?;
113    if !matches!(declared, ColumnType::Json) || value == Value::Null {
114        return Ok(value);
115    }
116    let DecodedColumn::Binary { data, offsets, .. } = decoded else {
117        return Ok(value);
118    };
119    // `decoded_cell_value` read this cell, so its byte range is in bounds.
120    let start = offsets[row_idx] as usize;
121    let end = offsets[row_idx + 1] as usize;
122    Ok(Value::Bytes(data[start..end].to_vec()))
123}
124
125#[cfg(test)]
126mod tests {
127    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
128    use nodedb_types::value::Value;
129
130    use crate::delete_bitmap::DeleteBitmap;
131    use crate::memtable::ColumnarMemtable;
132    use crate::reader::{DecodedColumn, SegmentReader};
133    use crate::test_support::test_memory;
134    use crate::writer::SegmentWriter;
135
136    use super::compact_segment;
137
138    fn test_schema() -> ColumnarSchema {
139        ColumnarSchema::new(vec![
140            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
141            ColumnDef::required("name", ColumnType::String),
142            ColumnDef::nullable("score", ColumnType::Float64),
143        ])
144        .expect("valid")
145    }
146
147    fn write_segment(rows: usize) -> Vec<u8> {
148        let schema = test_schema();
149        let mut mt = ColumnarMemtable::new(&schema);
150        for i in 0..rows {
151            mt.append_row(&[
152                Value::Integer(i as i64),
153                Value::String(format!("user_{i}")),
154                if i % 3 == 0 {
155                    Value::Null
156                } else {
157                    Value::Float(i as f64 * 0.5)
158                },
159            ])
160            .expect("append");
161        }
162        let (schema, columns, row_count) = mt.drain();
163        SegmentWriter::new(crate::writer::PROFILE_PLAIN, test_memory())
164            .write_segment(&schema, &columns, row_count, None)
165            .expect("write")
166    }
167
168    #[test]
169    fn compact_removes_deleted_rows() {
170        let segment = write_segment(100);
171        let mut deletes = DeleteBitmap::new();
172
173        // Delete rows 0, 10, 20, ..., 90 (10 rows).
174        for i in (0..100).step_by(10) {
175            deletes.mark_deleted(i);
176        }
177
178        let result = compact_segment(&segment, &deletes, &test_schema(), 0, &test_memory(), None)
179            .expect("compact");
180
181        assert_eq!(result.live_rows, 90);
182        assert_eq!(result.removed_rows, 10);
183        assert!(result.segment.is_some());
184
185        // Verify the compacted segment has correct row count.
186        let new_seg = result.segment.as_ref().expect("segment");
187        let reader = SegmentReader::open(new_seg).expect("open");
188        assert_eq!(reader.row_count(), 90);
189
190        // Verify that deleted rows are gone: row 0 (id=0) was deleted,
191        // so the first row should be id=1.
192        let col = reader.read_column(0).expect("read id");
193        match col {
194            DecodedColumn::Int64 { values, valid } => {
195                assert_eq!(values[0], 1); // First live row.
196                assert!(valid[0]);
197                // Row at index 8 should be id=9 (rows 0,10 deleted, so 1..9 = 9 rows, idx 8 = id 9).
198                assert_eq!(values[8], 9);
199            }
200            _ => panic!("expected Int64"),
201        }
202    }
203
204    #[test]
205    fn compact_all_deleted() {
206        let segment = write_segment(10);
207        let mut deletes = DeleteBitmap::new();
208        for i in 0..10 {
209            deletes.mark_deleted(i);
210        }
211
212        let result = compact_segment(&segment, &deletes, &test_schema(), 0, &test_memory(), None)
213            .expect("compact");
214
215        assert_eq!(result.live_rows, 0);
216        assert_eq!(result.removed_rows, 10);
217        assert!(result.segment.is_none());
218    }
219
220    #[test]
221    fn compact_no_deletes() {
222        let segment = write_segment(50);
223        let deletes = DeleteBitmap::new();
224
225        let result = compact_segment(&segment, &deletes, &test_schema(), 0, &test_memory(), None)
226            .expect("compact");
227
228        assert_eq!(result.live_rows, 50);
229        assert_eq!(result.removed_rows, 0);
230        assert!(result.segment.is_some());
231    }
232
233    #[test]
234    fn compact_preserves_string_data() {
235        let segment = write_segment(20);
236        let mut deletes = DeleteBitmap::new();
237        deletes.mark_deleted(0); // Delete first row.
238
239        let result = compact_segment(&segment, &deletes, &test_schema(), 0, &test_memory(), None)
240            .expect("compact");
241        let new_seg = result.segment.as_ref().expect("segment");
242        let reader = SegmentReader::open(new_seg).expect("open");
243
244        // Read the name column (string).
245        let col = reader.read_column(1).expect("read name");
246        match col {
247            DecodedColumn::Binary {
248                data,
249                offsets,
250                valid,
251            } => {
252                // First row should be "user_1" (user_0 was deleted).
253                let start = offsets[0] as usize;
254                let end = offsets[1] as usize;
255                let first_name = std::str::from_utf8(&data[start..end]).expect("utf8");
256                assert_eq!(first_name, "user_1");
257                assert!(valid[0]);
258            }
259            _ => panic!("expected Binary"),
260        }
261    }
262
263    /// Compaction carries every surviving cell unchanged: identifiers,
264    /// decimals, booleans, geometry text and JSON, a JSON string scalar
265    /// included.
266    #[test]
267    fn compact_preserves_typed_cells() {
268        let schema = ColumnarSchema::new(vec![
269            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
270            ColumnDef::nullable("u", ColumnType::Uuid),
271            ColumnDef::nullable("ul", ColumnType::Ulid),
272            ColumnDef::nullable("dec", ColumnType::Decimal(None)),
273            ColumnDef::nullable("b", ColumnType::Bool),
274            ColumnDef::nullable("geo", ColumnType::Geometry),
275            ColumnDef::nullable("j", ColumnType::Json),
276        ])
277        .expect("valid");
278        let mut mt = ColumnarMemtable::new(&schema);
279        for i in 0..6u128 {
280            let json = if i % 2 == 0 {
281                Value::String("\"scalar\"".into())
282            } else {
283                Value::Array(vec![Value::Integer(i as i64)])
284            };
285            mt.append_row(&[
286                Value::Integer(i as i64),
287                Value::Uuid(uuid::Uuid::from_u128(i + 1).to_string()),
288                Value::Ulid(ulid::Ulid(i + 1).to_string()),
289                Value::Decimal(rust_decimal::Decimal::new(i as i64 * 5, 1)),
290                Value::Bool(i % 3 == 0),
291                Value::String(format!("POINT({i} 2)")),
292                json,
293            ])
294            .expect("append");
295        }
296        let expected: Vec<Vec<Value>> = (1..6)
297            .map(|i| mt.get_row(i).expect("read").expect("row"))
298            .collect();
299        let (schema, columns, row_count) = mt.drain();
300        let segment = SegmentWriter::new(0, test_memory())
301            .write_segment(&schema, &columns, row_count, None)
302            .expect("write");
303
304        let mut deletes = DeleteBitmap::new();
305        deletes.mark_deleted(0);
306        let result =
307            compact_segment(&segment, &deletes, &schema, 0, &test_memory(), None).expect("compact");
308        let new_seg = result.segment.as_ref().expect("segment");
309        let reader = SegmentReader::open(new_seg).expect("open");
310        let indices: Vec<usize> = (0..schema.columns.len()).collect();
311        let decoded = reader.read_columns(&indices, &[]).expect("read");
312
313        for (row, want_row) in expected.iter().enumerate() {
314            for ((col, def), want) in decoded.iter().zip(&schema.columns).zip(want_row) {
315                let got = crate::reader::decoded_cell_value(col, row, &def.column_type)
316                    .expect("decodable cell");
317                assert_eq!(&got, want, "row {row} column '{}'", def.name);
318            }
319        }
320    }
321}