Skip to main content

uni_store/storage/
delta.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! LSM-style delta dataset for accumulating edge mutations before compaction.
5
6use crate::backend::StorageBackend;
7use crate::backend::table_names;
8use crate::backend::types::{FilterExpr, Scalar, ScalarIndexType, ScanRequest};
9use crate::storage::arrow_convert::build_timestamp_column;
10use crate::storage::property_builder::PropertyColumnBuilder;
11use crate::storage::value_codec::CrdtDecodeMode;
12use anyhow::{Result, anyhow};
13use arrow_array::types::TimestampNanosecondType;
14use arrow_array::{Array, ArrayRef, PrimitiveArray, RecordBatch, UInt8Array, UInt64Array};
15use arrow_schema::{Field, Schema as ArrowSchema, TimeUnit};
16use std::collections::HashMap;
17use std::sync::Arc;
18use tracing::info;
19use uni_common::DataType;
20use uni_common::Properties;
21use uni_common::core::id::{Eid, Vid};
22use uni_common::core::schema::Schema;
23
24/// Default maximum number of rows allowed in in-memory compaction operations.
25/// Set to 5 million rows to prevent OOM. For larger datasets, use chunked compaction.
26pub const DEFAULT_MAX_COMPACTION_ROWS: usize = 5_000_000;
27
28/// Estimated memory footprint per L1Entry in bytes (conservative estimate).
29/// Each entry has: src_vid (8), dst_vid (8), eid (8), op (1), version (8),
30/// properties (variable, ~64 avg), timestamps (16), overhead (~32) = ~145 bytes.
31pub const ENTRY_SIZE_ESTIMATE: usize = 145;
32
33/// Check whether loading `row_count` rows into memory would exceed `max_rows`.
34///
35/// Returns an error with a human-readable message including the estimated memory
36/// footprint. Used by both Lance and LanceDB scan paths.
37pub fn check_oom_guard(
38    row_count: usize,
39    max_rows: usize,
40    entity_name: &str,
41    qualifier: &str,
42) -> Result<()> {
43    if row_count > max_rows {
44        let estimated_bytes = row_count * ENTRY_SIZE_ESTIMATE;
45        return Err(anyhow!(
46            "Table for {}_{} has {} rows (estimated {:.2} GB in memory), exceeding max_compaction_rows limit of {}. \
47            Use chunked compaction or increase the limit. See issue #143.",
48            entity_name,
49            qualifier,
50            row_count,
51            estimated_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
52            max_rows
53        ));
54    }
55    Ok(())
56}
57
58/// Operation type stored in the delta (L1) log.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum Op {
61    /// Edge was inserted.
62    Insert = 0,
63    /// Edge was soft-deleted.
64    Delete = 1,
65}
66
67/// A single entry in the L1 (sorted run) delta dataset.
68#[derive(Clone, Debug)]
69pub struct L1Entry {
70    pub src_vid: Vid,
71    pub dst_vid: Vid,
72    pub eid: Eid,
73    pub op: Op,
74    pub version: u64,
75    pub properties: Properties,
76    /// Timestamp when the edge was created (nanoseconds since epoch).
77    pub created_at: Option<i64>,
78    /// Timestamp when the edge was last updated (nanoseconds since epoch).
79    pub updated_at: Option<i64>,
80}
81
82/// LSM-style delta dataset for a single edge type and direction.
83///
84/// Stores L1 sorted runs that accumulate edge mutations before compaction
85/// merges them into the base CSR.
86#[derive(Debug)]
87pub struct DeltaDataset {
88    edge_type: String,
89    direction: String, // "fwd" or "bwd"
90    /// Lance branch for branched reads. `None` = primary.
91    #[cfg_attr(not(feature = "lance-backend"), allow(dead_code))]
92    branch: Option<String>,
93}
94
95impl DeltaDataset {
96    /// Create a new `DeltaDataset` for the given edge type and direction.
97    ///
98    /// `_base_uri` is ignored: physical path resolution belongs to the storage
99    /// backend; this type holds only the table's logical identity.
100    pub fn new(_base_uri: &str, edge_type: &str, direction: &str) -> Self {
101        Self {
102            edge_type: edge_type.to_string(),
103            direction: direction.to_string(),
104            branch: None,
105        }
106    }
107
108    /// Construct a delta dataset that reads from a Lance branch.
109    pub fn new_branched(
110        base_uri: &str,
111        edge_type: &str,
112        direction: &str,
113        branch: impl Into<String>,
114    ) -> Self {
115        let mut ds = Self::new(base_uri, edge_type, direction);
116        ds.branch = Some(branch.into());
117        ds
118    }
119
120    /// Build the Arrow schema for this delta table using the given graph schema.
121    pub fn get_arrow_schema(&self, schema: &Schema) -> Result<Arc<ArrowSchema>> {
122        let mut fields = vec![
123            Field::new("src_vid", arrow_schema::DataType::UInt64, false),
124            Field::new("dst_vid", arrow_schema::DataType::UInt64, false),
125            Field::new("eid", arrow_schema::DataType::UInt64, false),
126            Field::new("op", arrow_schema::DataType::UInt8, false), // 0=INSERT, 1=DELETE
127            Field::new("_version", arrow_schema::DataType::UInt64, false),
128            // New timestamp columns per STORAGE_DESIGN.md
129            Field::new(
130                "_created_at",
131                arrow_schema::DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
132                true,
133            ),
134            Field::new(
135                "_updated_at",
136                arrow_schema::DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
137                true,
138            ),
139        ];
140
141        if let Some(type_props) = schema.properties.get(&self.edge_type) {
142            let mut sorted_props: Vec<_> = type_props.iter().collect();
143            sorted_props.sort_by_key(|(name, _)| *name);
144
145            for (name, meta) in sorted_props {
146                fields.push(Field::new(name, meta.r#type.to_arrow(), meta.nullable));
147            }
148        }
149
150        // Add overflow_json column for non-schema properties (JSONB binary format)
151        fields.push(Field::new(
152            "overflow_json",
153            arrow_schema::DataType::LargeBinary,
154            true,
155        ));
156
157        Ok(Arc::new(ArrowSchema::new(fields)))
158    }
159
160    /// Serialize `entries` into an Arrow `RecordBatch` using the given graph schema.
161    pub fn build_record_batch(&self, entries: &[L1Entry], schema: &Schema) -> Result<RecordBatch> {
162        let arrow_schema = self.get_arrow_schema(schema)?;
163
164        let mut src_vids = Vec::with_capacity(entries.len());
165        let mut dst_vids = Vec::with_capacity(entries.len());
166        let mut eids = Vec::with_capacity(entries.len());
167        let mut ops = Vec::with_capacity(entries.len());
168        let mut versions = Vec::with_capacity(entries.len());
169
170        for entry in entries {
171            src_vids.push(entry.src_vid.as_u64());
172            dst_vids.push(entry.dst_vid.as_u64());
173            eids.push(entry.eid.as_u64());
174            ops.push(entry.op as u8);
175            versions.push(entry.version);
176        }
177
178        let mut columns: Vec<ArrayRef> = vec![
179            Arc::new(UInt64Array::from(src_vids)),
180            Arc::new(UInt64Array::from(dst_vids)),
181            Arc::new(UInt64Array::from(eids)),
182            Arc::new(UInt8Array::from(ops)),
183            Arc::new(UInt64Array::from(versions)),
184        ];
185
186        // Build _created_at and _updated_at columns using shared builder
187        columns.push(build_timestamp_column(entries.iter().map(|e| e.created_at)));
188        columns.push(build_timestamp_column(entries.iter().map(|e| e.updated_at)));
189
190        // Derive deleted flags from Op for property column building
191        // Tombstones (Op::Delete) are logically deleted and should use default values
192        let deleted_flags: Vec<bool> = entries.iter().map(|e| e.op == Op::Delete).collect();
193
194        // Build property columns using shared builder
195        let prop_columns = PropertyColumnBuilder::new(schema, &self.edge_type, entries.len())
196            .with_deleted(&deleted_flags)
197            .build(|i| &entries[i].properties)?;
198
199        columns.extend(prop_columns);
200
201        // Build overflow_json column for non-schema properties
202        let overflow_column = self.build_overflow_json_column(entries, schema)?;
203        columns.push(overflow_column);
204
205        RecordBatch::try_new(arrow_schema, columns).map_err(|e| anyhow!(e))
206    }
207
208    /// Build the overflow_json column containing properties not in schema.
209    fn build_overflow_json_column(&self, entries: &[L1Entry], schema: &Schema) -> Result<ArrayRef> {
210        crate::storage::property_builder::build_overflow_json_column(
211            entries.len(),
212            &self.edge_type,
213            schema,
214            |i| &entries[i].properties,
215            &[],
216        )
217    }
218
219    /// Sort entries by direction key (src_vid for fwd, dst_vid for bwd) then by version.
220    fn sort_entries(&self, entries: &mut [L1Entry]) {
221        let is_fwd = self.direction == "fwd";
222        entries.sort_by(|a, b| {
223            let key_a = if is_fwd { a.src_vid } else { a.dst_vid };
224            let key_b = if is_fwd { b.src_vid } else { b.dst_vid };
225            key_a.cmp(&key_b).then(a.version.cmp(&b.version))
226        });
227    }
228
229    fn parse_batch(&self, batch: &RecordBatch, schema: &Schema) -> Result<Vec<L1Entry>> {
230        let src_vids = batch
231            .column_by_name("src_vid")
232            .ok_or(anyhow!("Missing src_vid"))?
233            .as_any()
234            .downcast_ref::<UInt64Array>()
235            .ok_or(anyhow!("Invalid src_vid type"))?;
236        let dst_vids = batch
237            .column_by_name("dst_vid")
238            .ok_or(anyhow!("Missing dst_vid"))?
239            .as_any()
240            .downcast_ref::<UInt64Array>()
241            .ok_or(anyhow!("Invalid dst_vid type"))?;
242        let eids = batch
243            .column_by_name("eid")
244            .ok_or(anyhow!("Missing eid"))?
245            .as_any()
246            .downcast_ref::<UInt64Array>()
247            .ok_or(anyhow!("Invalid eid type"))?;
248        let ops = batch
249            .column_by_name("op")
250            .ok_or(anyhow!("Missing op"))?
251            .as_any()
252            .downcast_ref::<UInt8Array>()
253            .ok_or(anyhow!("Invalid op type"))?;
254        let versions = batch
255            .column_by_name("_version")
256            .ok_or(anyhow!("Missing _version"))?
257            .as_any()
258            .downcast_ref::<UInt64Array>()
259            .ok_or(anyhow!("Invalid _version type"))?;
260
261        // Try to read timestamp columns (may not exist in old data)
262        let created_at_col = batch.column_by_name("_created_at").and_then(|c| {
263            c.as_any()
264                .downcast_ref::<PrimitiveArray<TimestampNanosecondType>>()
265        });
266        let updated_at_col = batch.column_by_name("_updated_at").and_then(|c| {
267            c.as_any()
268                .downcast_ref::<PrimitiveArray<TimestampNanosecondType>>()
269        });
270
271        // Prepare property columns
272        let mut prop_cols = Vec::new();
273        if let Some(type_props) = schema.properties.get(&self.edge_type) {
274            for (name, meta) in type_props {
275                if let Some(col) = batch.column_by_name(name) {
276                    prop_cols.push((name, meta.r#type.clone(), col));
277                }
278            }
279        }
280
281        let mut entries = Vec::with_capacity(batch.num_rows());
282
283        for i in 0..batch.num_rows() {
284            let op = match ops.value(i) {
285                0 => Op::Insert,
286                1 => Op::Delete,
287                _ => continue, // Unknown op
288            };
289
290            let properties = self.extract_properties(&prop_cols, i)?;
291
292            // Read timestamps if present
293            let read_ts = |col: Option<&PrimitiveArray<TimestampNanosecondType>>| {
294                col.and_then(|c| (!c.is_null(i)).then(|| c.value(i)))
295            };
296            let created_at = read_ts(created_at_col);
297            let updated_at = read_ts(updated_at_col);
298
299            entries.push(L1Entry {
300                src_vid: Vid::from(src_vids.value(i)),
301                dst_vid: Vid::from(dst_vids.value(i)),
302                eid: Eid::from(eids.value(i)),
303                op,
304                version: versions.value(i),
305                properties,
306                created_at,
307                updated_at,
308            });
309        }
310        Ok(entries)
311    }
312
313    /// Extract properties from columns for a single row.
314    fn extract_properties(
315        &self,
316        prop_cols: &[(&String, DataType, &ArrayRef)],
317        row: usize,
318    ) -> Result<Properties> {
319        let mut properties = Properties::new();
320        for (name, dtype, col) in prop_cols {
321            if col.is_null(row) {
322                continue;
323            }
324            let val = Self::value_from_column(col.as_ref(), dtype, row)?;
325            properties.insert(name.to_string(), uni_common::Value::from(val));
326        }
327        Ok(properties)
328    }
329
330    /// Decode an Arrow column value to JSON with lenient CRDT error handling.
331    fn value_from_column(
332        col: &dyn arrow_array::Array,
333        dtype: &uni_common::DataType,
334        row: usize,
335    ) -> Result<serde_json::Value> {
336        crate::storage::value_codec::value_from_column(col, dtype, row, CrdtDecodeMode::Lenient)
337    }
338
339    /// Returns the filter column name based on direction ("src_vid" for fwd, "dst_vid" for bwd).
340    fn filter_column(&self) -> &'static str {
341        if self.direction == "fwd" {
342            "src_vid"
343        } else {
344            "dst_vid"
345        }
346    }
347
348    // ========================================================================
349    // Backend-agnostic Methods
350    // ========================================================================
351
352    /// Open or create a delta table via the storage backend.
353    pub async fn open_or_create(
354        &self,
355        backend: &dyn StorageBackend,
356        schema: &Schema,
357    ) -> Result<()> {
358        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
359        let arrow_schema = self.get_arrow_schema(schema)?;
360        backend
361            .open_or_create_table(&table_name, arrow_schema)
362            .await
363    }
364
365    /// Write a run to a delta table.
366    ///
367    /// Creates the table if it doesn't exist, otherwise appends to it.
368    /// Race-safe under async-flush — see
369    /// `crate::storage::manager::write_batch_with_lance_conflict_retry`.
370    pub async fn write_run(&self, backend: &dyn StorageBackend, batch: RecordBatch) -> Result<()> {
371        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
372        crate::storage::manager::write_batch_with_lance_conflict_retry(backend, &table_name, batch)
373            .await
374    }
375
376    /// Build a partial-column RecordBatch for Lance `MergeInsert`.
377    /// Includes the join key (`eid`), the system columns (`src_vid`,
378    /// `dst_vid`, `op=0/Insert`, `_version`, `_updated_at`), and ONLY
379    /// the schema-defined property columns whose name appears in
380    /// `touched_keys`. If any of the entry's properties are NOT in the
381    /// label schema, `overflow_json` is regenerated with all overflow
382    /// properties present in the entry (the JSONB blob is one column;
383    /// it must be rewritten in full because we can't merge JSON
384    /// fragments at the storage layer).
385    ///
386    /// Used by Round-12 §A: edge SETs route through this builder so the
387    /// per-edge-type delta tables receive only the touched schema
388    /// columns; untouched columns retain their previous-version value
389    /// via Lance's MVCC `WhenMatched::UpdateAll` semantics.
390    pub fn build_partial_record_batch(
391        &self,
392        entries: &[L1Entry],
393        touched_keys: &std::collections::HashSet<String>,
394        schema: &Schema,
395    ) -> Result<RecordBatch> {
396        // Source schema: src_vid, dst_vid, eid, op, _version, _updated_at,
397        // touched schema cols, overflow_json (when any overflow key was
398        // touched).
399        let mut fields: Vec<Field> = vec![
400            Field::new("src_vid", arrow_schema::DataType::UInt64, false),
401            Field::new("dst_vid", arrow_schema::DataType::UInt64, false),
402            Field::new("eid", arrow_schema::DataType::UInt64, false),
403            Field::new("op", arrow_schema::DataType::UInt8, false),
404            Field::new("_version", arrow_schema::DataType::UInt64, false),
405            Field::new(
406                "_updated_at",
407                arrow_schema::DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
408                true,
409            ),
410        ];
411
412        let type_props = schema.properties.get(&self.edge_type);
413        let mut sorted_touched_props: Vec<(&String, &uni_common::core::schema::PropertyMeta)> =
414            if let Some(tp) = type_props {
415                tp.iter()
416                    .filter(|(name, _)| touched_keys.contains(*name))
417                    .collect()
418            } else {
419                Vec::new()
420            };
421        sorted_touched_props.sort_by_key(|(name, _)| *name);
422
423        for (name, meta) in &sorted_touched_props {
424            fields.push(Field::new(*name, meta.r#type.to_arrow(), meta.nullable));
425        }
426
427        // Determine if any touched key is a non-schema (overflow) prop —
428        // if so, regenerate overflow_json.
429        let schema_prop_names: std::collections::HashSet<&String> =
430            type_props.map(|tp| tp.keys().collect()).unwrap_or_default();
431        let any_overflow_touched = touched_keys.iter().any(|k| !schema_prop_names.contains(k));
432        if any_overflow_touched {
433            fields.push(Field::new(
434                "overflow_json",
435                arrow_schema::DataType::LargeBinary,
436                true,
437            ));
438        }
439
440        let arrow_schema = Arc::new(ArrowSchema::new(fields));
441
442        let mut columns: Vec<ArrayRef> = Vec::with_capacity(arrow_schema.fields().len());
443
444        let src_vids: Vec<u64> = entries.iter().map(|e| e.src_vid.as_u64()).collect();
445        let dst_vids: Vec<u64> = entries.iter().map(|e| e.dst_vid.as_u64()).collect();
446        let eids: Vec<u64> = entries.iter().map(|e| e.eid.as_u64()).collect();
447        let ops: Vec<u8> = entries.iter().map(|e| e.op as u8).collect();
448        let versions: Vec<u64> = entries.iter().map(|e| e.version).collect();
449        columns.push(Arc::new(UInt64Array::from(src_vids)));
450        columns.push(Arc::new(UInt64Array::from(dst_vids)));
451        columns.push(Arc::new(UInt64Array::from(eids)));
452        columns.push(Arc::new(UInt8Array::from(ops)));
453        columns.push(Arc::new(UInt64Array::from(versions)));
454        columns.push(build_timestamp_column(entries.iter().map(|e| e.updated_at)));
455
456        let default_deleted = vec![false; entries.len()];
457        for (name, meta) in &sorted_touched_props {
458            let extractor =
459                crate::storage::arrow_convert::PropertyExtractor::new(name, &meta.r#type);
460            let col = extractor.build_column(entries.len(), &default_deleted, |i| {
461                entries[i].properties.get(*name)
462            })?;
463            columns.push(col);
464        }
465
466        if any_overflow_touched {
467            let overflow_column = crate::storage::property_builder::build_overflow_json_column(
468                entries.len(),
469                &self.edge_type,
470                schema,
471                |i| &entries[i].properties,
472                &[],
473            )?;
474            columns.push(overflow_column);
475        }
476
477        RecordBatch::try_new(arrow_schema, columns).map_err(|e| anyhow!(e))
478    }
479
480    /// MergeInsert a partial-column batch via Lance. Join key is `eid`.
481    /// Matched rows have `WhenMatched::UpdateAll` applied; unmatched
482    /// source rows are dropped (a partial SET can only update an edge
483    /// that was previously CREATEd — the full-row Append path landed
484    /// the original row).
485    pub async fn merge_insert_partial_run(
486        &self,
487        backend: &dyn StorageBackend,
488        batch: RecordBatch,
489    ) -> Result<()> {
490        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
491        crate::storage::manager::merge_insert_batch_with_lance_conflict_retry(
492            backend,
493            &table_name,
494            batch,
495            &["eid"],
496        )
497        .await
498    }
499
500    /// Ensure a BTree index exists on the 'eid' column.
501    pub async fn ensure_eid_index(&self, backend: &dyn StorageBackend) -> Result<()> {
502        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
503        let indices = backend.list_indexes(&table_name).await?;
504
505        if !indices
506            .iter()
507            .any(|idx| idx.columns.contains(&"eid".to_string()))
508        {
509            log::info!(
510                "Creating eid BTree index for edge type '{}'",
511                self.edge_type
512            );
513            if let Err(e) = backend
514                .create_scalar_index(&table_name, &["eid"], ScalarIndexType::BTree, None)
515                .await
516            {
517                log::warn!("Failed to create eid index for '{}': {}", self.edge_type, e);
518            }
519        }
520
521        Ok(())
522    }
523
524    /// Get the table name for this delta dataset.
525    pub fn table_name(&self) -> String {
526        table_names::delta_table_name(&self.edge_type, &self.direction)
527    }
528
529    /// Scan all entries from the backend table.
530    ///
531    /// Returns an empty vector if the table doesn't exist.
532    pub async fn scan_all_backend(
533        &self,
534        backend: &dyn StorageBackend,
535        schema: &Schema,
536    ) -> Result<Vec<L1Entry>> {
537        self.scan_all_backend_with_limit(backend, schema, DEFAULT_MAX_COMPACTION_ROWS)
538            .await
539    }
540
541    /// Scan all entries from the backend table with a configurable row limit to prevent OOM.
542    pub async fn scan_all_backend_with_limit(
543        &self,
544        backend: &dyn StorageBackend,
545        schema: &Schema,
546        max_rows: usize,
547    ) -> Result<Vec<L1Entry>> {
548        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
549
550        if !backend.table_exists(&table_name).await? {
551            return Ok(vec![]);
552        }
553
554        let row_count = backend.count_rows(&table_name, None).await?;
555        check_oom_guard(row_count, max_rows, &self.edge_type, &self.direction)?;
556
557        info!(
558            edge_type = %self.edge_type,
559            direction = %self.direction,
560            row_count,
561            estimated_bytes = row_count * ENTRY_SIZE_ESTIMATE,
562            "Starting delta scan for compaction (backend)"
563        );
564
565        let batches = backend.scan(ScanRequest::all(&table_name)).await?;
566
567        let mut entries = Vec::new();
568        for batch in batches {
569            let mut batch_entries = self.parse_batch(&batch, schema)?;
570            entries.append(&mut batch_entries);
571        }
572
573        self.sort_entries(&mut entries);
574
575        Ok(entries)
576    }
577
578    /// Replace the delta table with a new batch (atomic replacement).
579    ///
580    /// This is used during compaction to clear the delta table after merging into L2.
581    pub async fn replace(&self, backend: &dyn StorageBackend, batch: RecordBatch) -> Result<()> {
582        let table_name = self.table_name();
583        let arrow_schema = batch.schema();
584        backend
585            .replace_table_atomic(&table_name, vec![batch], arrow_schema)
586            .await
587    }
588
589    /// Delete every delta row at or below a version high-water-mark.
590    ///
591    /// Compaction uses this to clear ONLY the deltas it actually merged into L2
592    /// — the ones present at read time, whose max `_version` is `hwm`. Unlike a
593    /// full table wipe ([`Self::replace`] with an empty batch), this preserves rows a
594    /// concurrent flush appended after the compaction read them: those carry a
595    /// strictly higher `_version` (flush versions are monotonic and a flush's
596    /// deltas land atomically), so `_version <= hwm` never matches them. This is
597    /// what makes the clear safe without depending on an instantaneous
598    /// `flush_in_progress` check. (review H11)
599    pub async fn delete_up_to_version(&self, backend: &dyn StorageBackend, hwm: u64) -> Result<()> {
600        let table_name = self.table_name();
601        if !backend.table_exists(&table_name).await? {
602            return Ok(());
603        }
604        backend
605            .delete_rows(&table_name, &FilterExpr::version_at_most(hwm))
606            .await
607    }
608
609    /// Read delta entries for a specific vertex ID.
610    ///
611    /// Returns an empty vector if the table doesn't exist or no entries match.
612    pub async fn read_deltas(
613        &self,
614        backend: &dyn StorageBackend,
615        vid: Vid,
616        schema: &Schema,
617        version_hwm: Option<u64>,
618    ) -> Result<Vec<L1Entry>> {
619        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
620
621        if !backend.table_exists(&table_name).await? {
622            return Ok(vec![]);
623        }
624
625        let base_filter = FilterExpr::equals(self.filter_column(), Scalar::UInt(vid.as_u64()));
626
627        // Add version filtering if snapshot is active
628        let final_filter = match version_hwm {
629            Some(hwm) => FilterExpr::all([base_filter, FilterExpr::version_at_most(hwm)]),
630            None => base_filter,
631        };
632
633        let batches = backend
634            .scan(ScanRequest::all(&table_name).with_filter(final_filter))
635            .await?;
636
637        let mut entries = Vec::new();
638        for batch in batches {
639            let mut batch_entries = self.parse_batch(&batch, schema)?;
640            entries.append(&mut batch_entries);
641        }
642
643        Ok(entries)
644    }
645
646    /// Read delta entries for multiple vertex IDs in a single batch query.
647    ///
648    /// Returns a HashMap mapping each vid to its delta entries.
649    /// VIDs with no delta entries will not be in the map.
650    pub async fn read_deltas_batch(
651        &self,
652        backend: &dyn StorageBackend,
653        vids: &[Vid],
654        schema: &Schema,
655        version_hwm: Option<u64>,
656    ) -> Result<HashMap<Vid, Vec<L1Entry>>> {
657        if vids.is_empty() {
658            return Ok(HashMap::new());
659        }
660
661        let table_name = table_names::delta_table_name(&self.edge_type, &self.direction);
662
663        if !backend.table_exists(&table_name).await? {
664            return Ok(HashMap::new());
665        }
666
667        let mut filter = FilterExpr::one_of(
668            self.filter_column(),
669            vids.iter().map(|v| Scalar::UInt(v.as_u64())),
670        );
671
672        // Add version filtering if snapshot is active
673        if let Some(hwm) = version_hwm {
674            filter = FilterExpr::all([filter, FilterExpr::version_at_most(hwm)]);
675        }
676
677        let batches = backend
678            .scan(ScanRequest::all(&table_name).with_filter(filter))
679            .await?;
680
681        // Parse all batches and group by direction key VID
682        let is_fwd = self.direction == "fwd";
683        let mut result: HashMap<Vid, Vec<L1Entry>> = HashMap::new();
684        for batch in batches {
685            let entries = self.parse_batch(&batch, schema)?;
686            for entry in entries {
687                let vid = if is_fwd { entry.src_vid } else { entry.dst_vid };
688                result.entry(vid).or_default().push(entry);
689            }
690        }
691
692        Ok(result)
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    #[expect(
702        clippy::assertions_on_constants,
703        reason = "Validating configuration constants intentionally"
704    )]
705    fn test_constants_are_reasonable() {
706        // Verify DEFAULT_MAX_COMPACTION_ROWS is set to 5 million
707        assert_eq!(DEFAULT_MAX_COMPACTION_ROWS, 5_000_000);
708
709        // Verify ENTRY_SIZE_ESTIMATE is reasonable (should be between 100-300 bytes)
710        assert!(ENTRY_SIZE_ESTIMATE >= 100, "Entry size estimate too low");
711        assert!(ENTRY_SIZE_ESTIMATE <= 300, "Entry size estimate too high");
712
713        // Verify that 5M entries at the estimated size fits in reasonable memory
714        let estimated_gb =
715            (DEFAULT_MAX_COMPACTION_ROWS * ENTRY_SIZE_ESTIMATE) as f64 / (1024.0 * 1024.0 * 1024.0);
716        assert!(
717            estimated_gb < 1.0,
718            "5M entries should fit in under 1GB with current estimate"
719        );
720    }
721
722    #[test]
723    fn test_memory_estimate_formatting() {
724        // Test that our GB formatting works correctly
725        let row_count = 10_000_000;
726        let estimated_bytes = row_count * ENTRY_SIZE_ESTIMATE;
727        let estimated_gb = estimated_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
728
729        // Should be around 1.35 GB for 10M rows
730        assert!(
731            estimated_gb > 1.0 && estimated_gb < 2.0,
732            "10M rows should be 1-2 GB"
733        );
734    }
735
736    #[test]
737    fn test_check_oom_guard_below_limit() {
738        let result = check_oom_guard(1_000_000, 5_000_000, "KNOWS", "fwd");
739        assert!(result.is_ok());
740    }
741
742    #[test]
743    fn test_check_oom_guard_at_limit() {
744        let result = check_oom_guard(5_000_000, 5_000_000, "KNOWS", "fwd");
745        assert!(result.is_ok());
746    }
747
748    #[test]
749    fn test_check_oom_guard_above_limit() {
750        let result = check_oom_guard(5_000_001, 5_000_000, "KNOWS", "fwd");
751        assert!(result.is_err());
752        let msg = result.unwrap_err().to_string();
753        assert!(msg.contains("KNOWS_fwd"), "Error should name the entity");
754        assert!(msg.contains("5000001"), "Error should state the row count");
755        assert!(msg.contains("GB"), "Error should show GB estimate");
756        assert!(msg.contains("issue #143"), "Error should reference issue");
757    }
758
759    #[test]
760    fn test_op_values() {
761        assert_eq!(Op::Insert as u8, 0);
762        assert_eq!(Op::Delete as u8, 1);
763    }
764
765    fn entry(eid: u64, version: u64) -> L1Entry {
766        L1Entry {
767            src_vid: Vid::new(1),
768            dst_vid: Vid::new(2),
769            eid: Eid::new(eid),
770            op: Op::Insert,
771            version,
772            properties: Properties::new(),
773            created_at: None,
774            updated_at: None,
775        }
776    }
777
778    /// H11: compaction must clear ONLY the deltas it read (those at or below the
779    /// high-water-mark), so a delta a concurrent flush appended with a higher
780    /// `_version` after the read survives instead of being wiped by a full
781    /// table replace.
782    #[tokio::test]
783    async fn delete_up_to_version_preserves_newer_deltas() -> Result<()> {
784        use crate::backend::lance::LanceDbBackend;
785
786        let dir = tempfile::tempdir()?;
787        let uri = dir.path().to_str().unwrap();
788        let be = LanceDbBackend::connect(uri, None).await?;
789        let backend: &dyn StorageBackend = &be;
790        let schema = Schema::default();
791        let ds = DeltaDataset::new(uri, "KNOWS", "fwd");
792
793        // Compaction-visible deltas at versions 1 and 2 (hwm = 2).
794        let merged = ds.build_record_batch(&[entry(10, 1), entry(11, 2)], &schema)?;
795        ds.write_run(backend, merged).await?;
796
797        // A concurrent flush appends a NEWER delta (version 3) after the read.
798        let newer = ds.build_record_batch(&[entry(12, 3)], &schema)?;
799        ds.write_run(backend, newer).await?;
800
801        // Clear what compaction merged (hwm = 2). The version-3 row must remain.
802        ds.delete_up_to_version(backend, 2).await?;
803
804        let remaining = ds.scan_all_backend(backend, &schema).await?;
805        assert_eq!(
806            remaining.len(),
807            1,
808            "only the concurrently-appended version-3 delta should remain"
809        );
810        assert_eq!(remaining[0].eid, Eid::new(12));
811        assert_eq!(remaining[0].version, 3);
812        Ok(())
813    }
814}