Skip to main content

nodedb_columnar/mutation/
write.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Write-path mutations: insert, insert_if_absent, delete, update.
4
5use nodedb_types::surrogate::Surrogate;
6use nodedb_types::value::Value;
7
8use crate::error::ColumnarError;
9use crate::pk_index::{RowLocation, encode_pk};
10use crate::wal_record::{ColumnarWalRecord, encode_row_for_wal};
11
12use super::engine::{MutationEngine, MutationResult};
13
14impl MutationEngine {
15    /// Insert a row with upsert-on-duplicate semantics. Returns WAL
16    /// records to persist.
17    ///
18    /// Validates schema. If the PK already exists, the prior row is
19    /// tombstoned via the segment's delete bitmap (a single positional
20    /// delete) after the new row is appended to the memtable. A row that
21    /// fails validation returns an error and leaves the prior row live. The PK
22    /// index is rebound to the new row location. This matches the
23    /// ClickHouse / Iceberg "sparse PK + positional delete" model and
24    /// keeps `SELECT WHERE pk = X` linearizable on one row without a
25    /// read-time merge pass.
26    ///
27    /// Callers that want strict INSERT (error on duplicate) should check
28    /// `pk_index().contains()` themselves before calling; callers that
29    /// want `ON CONFLICT DO NOTHING` semantics should use
30    /// [`Self::insert_if_absent`].
31    pub fn insert(&mut self, values: &[Value]) -> Result<MutationResult, ColumnarError> {
32        self.upsert_row(values, None)
33    }
34
35    /// Insert with a stable cross-engine surrogate identity.
36    ///
37    /// Identical to [`Self::insert`] but also records `surrogate` in the
38    /// per-row side-table so scan prefilters can perform bitmap membership
39    /// checks without a separate lookup pass.
40    pub fn insert_with_surrogate(
41        &mut self,
42        values: &[Value],
43        surrogate: Surrogate,
44    ) -> Result<MutationResult, ColumnarError> {
45        self.upsert_row(values, Some(surrogate))
46    }
47
48    /// Shared body of [`Self::insert`] and [`Self::insert_with_surrogate`].
49    ///
50    /// A non-bitemporal write tombstones the prior row for the PK. A
51    /// bitemporal write keeps every version of a PK: the prior row stays
52    /// visible to `AS OF` queries, and the PK index still rebinds so
53    /// current-state reads see the latest version.
54    fn upsert_row(
55        &mut self,
56        values: &[Value],
57        surrogate: Option<Surrogate>,
58    ) -> Result<MutationResult, ColumnarError> {
59        let pk_bytes = self.extract_pk_bytes(values)?;
60        let prior = if self.schema.is_bitemporal() {
61            None
62        } else {
63            self.pk_index.get(&pk_bytes).copied()
64        };
65        let mut wal_records = Vec::with_capacity(2);
66        self.commit_row(
67            values,
68            pk_bytes,
69            surrogate,
70            prior.as_slice(),
71            &mut wal_records,
72        )?;
73        Ok(MutationResult { wal_records })
74    }
75
76    /// Append `values` as a memtable row bound to `pk_bytes`, then
77    /// tombstone each row in `replaced`.
78    ///
79    /// Every fallible step runs before any state changes:
80    /// `encode_row_for_wal` runs first, and `append_row` is all-or-nothing.
81    /// An error leaves the engine and `wal_records` unchanged. Tombstone
82    /// records go into `wal_records` before the insert record, so replay
83    /// applies them in the same order.
84    pub(super) fn commit_row(
85        &mut self,
86        values: &[Value],
87        pk_bytes: Vec<u8>,
88        surrogate: Option<Surrogate>,
89        replaced: &[RowLocation],
90        wal_records: &mut Vec<ColumnarWalRecord>,
91    ) -> Result<(), ColumnarError> {
92        let row_data = encode_row_for_wal(values)?;
93        self.memtable.append_row(values)?;
94
95        for prior in replaced {
96            self.delete_bitmaps
97                .entry(prior.segment_id)
98                .or_default()
99                .mark_deleted(prior.row_index);
100            wal_records.push(ColumnarWalRecord::DeleteRows {
101                collection: self.collection.clone(),
102                segment_id: prior.segment_id,
103                row_indices: vec![prior.row_index],
104            });
105        }
106        wal_records.push(ColumnarWalRecord::InsertRow {
107            collection: self.collection.clone(),
108            row_data,
109        });
110
111        let location = RowLocation {
112            segment_id: self.memtable_segment_id,
113            row_index: self.memtable_row_counter,
114        };
115        self.pk_index.upsert(pk_bytes, location);
116        self.memtable_surrogates.push(surrogate);
117        self.memtable_row_counter += 1;
118        Ok(())
119    }
120
121    /// `INSERT ... ON CONFLICT DO NOTHING` semantics: append only if the
122    /// PK is absent; silently skip on duplicate.
123    ///
124    /// Returns `Ok(MutationResult { wal_records })` with an empty vector
125    /// when the row was skipped, so callers that batch WAL appends can
126    /// detect no-ops by checking `wal_records.is_empty()`.
127    pub fn insert_if_absent(&mut self, values: &[Value]) -> Result<MutationResult, ColumnarError> {
128        let pk_bytes = self.extract_pk_bytes(values)?;
129        if self.pk_index.contains(&pk_bytes) {
130            return Ok(MutationResult {
131                wal_records: Vec::new(),
132            });
133        }
134        let mut wal_records = Vec::with_capacity(1);
135        self.commit_row(values, pk_bytes, None, &[], &mut wal_records)?;
136        Ok(MutationResult { wal_records })
137    }
138
139    /// Look up the current row for a PK in the memtable, if present.
140    ///
141    /// Returns `None` if the PK is not in the index, or if the PK points
142    /// to a flushed segment (callers needing cross-segment lookup must
143    /// go through a segment reader separately). This is the fast path
144    /// used by `ON CONFLICT DO UPDATE` to read the would-be-merged row
145    /// when the duplicate hits the memtable — the common case under
146    /// back-to-back inserts.
147    ///
148    /// `Err` when a cell of the bound row is corrupt: a corrupt row is not an
149    /// absent row.
150    pub fn lookup_memtable_row_by_pk(
151        &self,
152        pk_bytes: &[u8],
153    ) -> Result<Option<Vec<Value>>, ColumnarError> {
154        let Some(loc) = self.pk_index.get(pk_bytes).copied() else {
155            return Ok(None);
156        };
157        if loc.segment_id != self.memtable_segment_id {
158            return Ok(None);
159        }
160        self.memtable
161            .get_row(loc.row_index as usize)
162            .map_err(|e| self.memtable_read_fault(e))
163    }
164
165    /// Delete a row by PK value. Returns WAL record to persist.
166    ///
167    /// Looks up PK in the index to find the segment + row, then marks
168    /// the row in the segment's delete bitmap.
169    pub fn delete(&mut self, pk_value: &Value) -> Result<MutationResult, ColumnarError> {
170        let pk_bytes = encode_pk(pk_value);
171
172        let location = self
173            .pk_index
174            .get(&pk_bytes)
175            .copied()
176            .ok_or(ColumnarError::PrimaryKeyNotFound)?;
177
178        // Generate WAL record BEFORE applying.
179        let wal = ColumnarWalRecord::DeleteRows {
180            collection: self.collection.clone(),
181            segment_id: location.segment_id,
182            row_indices: vec![location.row_index],
183        };
184
185        // Mark in delete bitmap.
186        let bitmap = self.delete_bitmaps.entry(location.segment_id).or_default();
187        bitmap.mark_deleted(location.row_index);
188
189        // Remove from PK index.
190        self.pk_index.remove(&pk_bytes);
191
192        Ok(MutationResult {
193            wal_records: vec![wal],
194        })
195    }
196
197    /// Update a row by PK: DELETE old + INSERT new.
198    ///
199    /// `updates` maps column names to new values. Columns not in the map
200    /// retain their existing values from the old row.
201    ///
202    /// Returns WAL records for both the delete and the insert. The old row
203    /// is tombstoned only after the new row is appended, so an invalid new
204    /// row returns an error and leaves the old row live.
205    ///
206    /// NOTE: The caller must provide the full old row values for the re-insert.
207    /// This method takes the complete new row (already merged with old values).
208    ///
209    /// The new row keeps the cross-engine surrogate the old row carried. A
210    /// memtable row's surrogate is in this engine's side table. A flushed
211    /// row's surrogate is in its segment's sidecar, outside this engine, so
212    /// the caller passes it as `flushed_surrogate`. It is read only when the
213    /// old row is flushed.
214    pub fn update(
215        &mut self,
216        old_pk: &Value,
217        new_values: &[Value],
218        flushed_surrogate: Option<Surrogate>,
219    ) -> Result<MutationResult, ColumnarError> {
220        let old_pk_bytes = encode_pk(old_pk);
221        let old_location = self
222            .pk_index
223            .get(&old_pk_bytes)
224            .copied()
225            .ok_or(ColumnarError::PrimaryKeyNotFound)?;
226        let surrogate = if old_location.segment_id == self.memtable_segment_id {
227            self.memtable_surrogates
228                .get(old_location.row_index as usize)
229                .copied()
230                .flatten()
231        } else {
232            flushed_surrogate
233        };
234
235        let new_pk_bytes = self.extract_pk_bytes(new_values)?;
236        let pk_changed = new_pk_bytes != old_pk_bytes;
237
238        // The old row is always tombstoned. When the PK changes, a
239        // non-bitemporal row already bound to the new PK is replaced too,
240        // as an upsert replaces it.
241        let mut replaced = Vec::with_capacity(2);
242        replaced.push(old_location);
243        if pk_changed
244            && !self.schema.is_bitemporal()
245            && let Some(prior) = self.pk_index.get(&new_pk_bytes).copied()
246        {
247            replaced.push(prior);
248        }
249
250        let mut wal_records = Vec::with_capacity(replaced.len() + 1);
251        self.commit_row(
252            new_values,
253            new_pk_bytes,
254            surrogate,
255            &replaced,
256            &mut wal_records,
257        )?;
258        if pk_changed {
259            self.pk_index.remove(&old_pk_bytes);
260        }
261
262        Ok(MutationResult { wal_records })
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
269
270    use super::*;
271
272    fn schema() -> ColumnarSchema {
273        ColumnarSchema::new(vec![
274            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
275            ColumnDef::required("name", ColumnType::String),
276            ColumnDef::nullable("score", ColumnType::Float64),
277        ])
278        .expect("valid")
279    }
280
281    fn row(id: i64, name: &str, score: f64) -> Vec<Value> {
282        vec![
283            Value::Integer(id),
284            Value::String(name.into()),
285            Value::Float(score),
286        ]
287    }
288
289    fn live_rows(engine: &MutationEngine) -> Vec<Vec<Value>> {
290        engine
291            .scan_memtable_rows()
292            .collect::<Result<_, _>>()
293            .expect("read")
294    }
295
296    #[test]
297    fn rejected_upsert_keeps_prior_row() {
298        let mut engine = MutationEngine::new("t".into(), schema());
299        engine.insert(&row(1, "a", 0.5)).expect("insert");
300
301        let err = engine
302            .insert(&[Value::Integer(1), Value::Null, Value::Null])
303            .unwrap_err();
304        assert!(matches!(err, ColumnarError::NullViolation(_)));
305
306        assert_eq!(live_rows(&engine), vec![row(1, "a", 0.5)]);
307        assert_eq!(engine.memtable().row_count(), 1);
308        let loc = engine
309            .pk_index()
310            .get(&encode_pk(&Value::Integer(1)))
311            .copied();
312        assert_eq!(loc.map(|l| l.row_index), Some(0));
313        assert!(
314            engine
315                .delete_bitmap(engine.memtable_segment_id())
316                .is_none_or(|bm| !bm.is_deleted(0))
317        );
318    }
319
320    #[test]
321    fn rejected_upsert_with_surrogate_keeps_prior_row() {
322        let mut engine = MutationEngine::new("t".into(), schema());
323        engine
324            .insert_with_surrogate(&row(1, "a", 0.5), Surrogate(7))
325            .expect("insert");
326
327        let err = engine
328            .insert_with_surrogate(
329                &[
330                    Value::Integer(1),
331                    Value::String("b".into()),
332                    Value::Bool(true),
333                ],
334                Surrogate(8),
335            )
336            .unwrap_err();
337        assert!(matches!(err, ColumnarError::TypeMismatch { .. }));
338
339        assert_eq!(live_rows(&engine), vec![row(1, "a", 0.5)]);
340        assert_eq!(engine.memtable_surrogates(), &[Some(Surrogate(7))]);
341    }
342
343    #[test]
344    fn rejected_insert_if_absent_leaves_engine_unchanged() {
345        let mut engine = MutationEngine::new("t".into(), schema());
346        let err = engine
347            .insert_if_absent(&[Value::Integer(1), Value::Null, Value::Null])
348            .unwrap_err();
349        assert!(matches!(err, ColumnarError::NullViolation(_)));
350        assert!(engine.pk_index().is_empty());
351        assert_eq!(engine.memtable().row_count(), 0);
352
353        engine
354            .insert_if_absent(&row(1, "a", 0.5))
355            .expect("insert after rejected row");
356        assert_eq!(live_rows(&engine), vec![row(1, "a", 0.5)]);
357    }
358
359    #[test]
360    fn rejected_update_keeps_old_row() {
361        let mut engine = MutationEngine::new("t".into(), schema());
362        engine.insert(&row(1, "a", 0.5)).expect("insert");
363
364        let err = engine
365            .update(
366                &Value::Integer(1),
367                &[Value::Integer(1), Value::Null, Value::Null],
368                None,
369            )
370            .unwrap_err();
371        assert!(matches!(err, ColumnarError::NullViolation(_)));
372
373        assert_eq!(live_rows(&engine), vec![row(1, "a", 0.5)]);
374        assert!(engine.pk_index().contains(&encode_pk(&Value::Integer(1))));
375    }
376
377    #[test]
378    fn update_to_new_pk_rebinds_index() {
379        let mut engine = MutationEngine::new("t".into(), schema());
380        engine.insert(&row(1, "a", 0.5)).expect("insert");
381
382        let result = engine
383            .update(&Value::Integer(1), &row(2, "b", 0.25), None)
384            .expect("update");
385        assert_eq!(result.wal_records.len(), 2);
386
387        assert_eq!(live_rows(&engine), vec![row(2, "b", 0.25)]);
388        assert!(!engine.pk_index().contains(&encode_pk(&Value::Integer(1))));
389        assert!(engine.pk_index().contains(&encode_pk(&Value::Integer(2))));
390    }
391}