Skip to main content

ailake_query/
delete.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Iceberg V3 Deletion Vector write support — Phase C.
3//
4// Produces Roaring Bitmap blobs in minimal Puffin `.dvd` files and updates
5// the manifest entry so scanners (Phase B) automatically mask deleted rows
6// from HNSW and flat-scan results.
7//
8// Phase B (read) is independent: existing DVs written by Spark / Trino /
9// PyIceberg are consumed without requiring Phase C.
10
11use std::sync::Arc;
12
13use bytes::Bytes;
14use roaring::RoaringBitmap;
15
16use ailake_catalog::{
17    provider::{
18        new_snapshot_id, CatalogProvider, DeletionVector, NewSnapshot, SnapshotOperation,
19        TableIdent,
20    },
21    DataFileEntry,
22};
23use ailake_core::{AilakeError, AilakeResult};
24use ailake_store::Store;
25
26use crate::dv::load_deletion_vector;
27
28// ── Puffin writer ─────────────────────────────────────────────────────────────
29
30/// Puffin magic bytes — per Iceberg Puffin spec §2.
31const PUFFIN_MAGIC: &[u8] = b"PFAc";
32
33/// Minimal single-blob Puffin file writer for Deletion Vectors.
34///
35/// Puffin format (simplified, one blob):
36/// ```text
37/// [4 bytes magic "PFAc"] [blob bytes] [footer JSON] [4 bytes footer_len LE] [4 bytes magic "PFAc"]
38/// ```
39/// The DV manifest entry stores `offset=4` (after magic) and `length=blob.len()`, so
40/// readers skip the Puffin header/footer and fetch only the bitmap bytes via range GET.
41pub struct PuffinWriter;
42
43impl PuffinWriter {
44    /// Serialize `bitmap` into a single-blob Puffin file.
45    ///
46    /// Returns `(file_bytes, blob_offset, blob_length)`.
47    pub fn write_single_dv(
48        bitmap: &RoaringBitmap,
49        snapshot_id: i64,
50    ) -> AilakeResult<(Bytes, u64, u64)> {
51        let mut blob = Vec::new();
52        bitmap
53            .serialize_into(&mut blob)
54            .map_err(|e| AilakeError::Io(std::io::Error::other(format!("DV serialize: {e}"))))?;
55
56        let blob_offset = PUFFIN_MAGIC.len() as u64;
57        let blob_length = blob.len() as u64;
58
59        // Footer JSON per Iceberg Puffin spec §4.
60        let footer_json = serde_json::json!({
61            "blobs": [{
62                "type": "deletion-vector-v1",
63                "snapshot-id": snapshot_id,
64                "sequence-number": 0,
65                "offset": blob_offset,
66                "length": blob_length
67            }],
68            "properties": {}
69        })
70        .to_string();
71        let footer_bytes = footer_json.as_bytes();
72        let footer_len = (footer_bytes.len() as u32).to_le_bytes();
73
74        let mut out =
75            Vec::with_capacity(PUFFIN_MAGIC.len() * 2 + blob.len() + footer_bytes.len() + 4);
76        out.extend_from_slice(PUFFIN_MAGIC);
77        out.extend_from_slice(&blob);
78        out.extend_from_slice(footer_bytes);
79        out.extend_from_slice(&footer_len);
80        out.extend_from_slice(PUFFIN_MAGIC);
81
82        Ok((Bytes::from(out), blob_offset, blob_length))
83    }
84}
85
86// ── Public API ────────────────────────────────────────────────────────────────
87
88/// Logically delete rows from a V3 AI-Lake table using Iceberg Deletion Vectors.
89///
90/// # What this does
91/// 1. Verifies the table is `format-version=3` (DVs require V3).
92/// 2. Reads the current file list from the catalog.
93/// 3. Finds `file_path` in the snapshot (exact match or suffix match for
94///    tables where the catalog prefixes absolute paths).
95/// 4. Merges `row_ids` into the existing DV bitmap for that file (or creates
96///    a new one if the file has no DV yet).
97/// 5. Writes a new Puffin `.dvd` file to `{table_location}/metadata/dv-{snap_id}.dvd`.
98/// 6. Commits a `Replace` snapshot so all readers see the updated DV immediately.
99///
100/// After the call, `scanner.rs` (Phase B) will automatically exclude the
101/// deleted rows from HNSW and flat-scan results. The data file is not modified.
102///
103/// # Arguments
104/// * `catalog` — catalog for manifest reads and snapshot commits.
105/// * `store` — object store for Puffin file I/O.
106/// * `table` — fully-qualified table identifier (`namespace.name`).
107/// * `file_path` — path of the data file whose rows are being deleted.
108///   May be a relative path (e.g. `"data/part-00001.parquet"`) or an absolute
109///   path as returned by `catalog.list_files()`. Suffix matching is applied.
110/// * `row_ids` — 0-based row positions to delete (within the data file).
111///
112/// # Errors
113/// * `InvalidArgument` if the table is `format-version < 3`.
114/// * `Catalog` if the table has no current snapshot or `file_path` is not found.
115pub async fn delete_rows(
116    catalog: Arc<dyn CatalogProvider>,
117    store: Arc<dyn Store>,
118    table: &TableIdent,
119    file_path: &str,
120    row_ids: &[u32],
121) -> AilakeResult<()> {
122    if row_ids.is_empty() {
123        return Ok(());
124    }
125
126    // Verify table is V3.
127    let meta = catalog.load_table(table).await?;
128    if meta.format_version < 3 {
129        return Err(AilakeError::InvalidArgument(format!(
130            "Deletion Vectors require Iceberg V3 table (got format-version={}). \
131             Recreate the table with format_version=3.",
132            meta.format_version
133        )));
134    }
135
136    // Load current file list.
137    let mut files: Vec<DataFileEntry> = catalog.list_files(table, None).await?;
138
139    // Find target file (exact match or suffix match for absolute-path manifests).
140    let target_idx = files
141        .iter()
142        .position(|f| f.path == file_path || f.path.ends_with(file_path))
143        .ok_or_else(|| {
144            AilakeError::Catalog(format!("file '{file_path}' not found in current snapshot"))
145        })?;
146
147    // Build bitmap: merge existing DV with new row_ids.
148    let mut bitmap = if let Some(ref dv) = files[target_idx].deletion_vector {
149        load_deletion_vector(&store, dv).await.unwrap_or_default()
150    } else {
151        RoaringBitmap::new()
152    };
153    for &id in row_ids {
154        bitmap.insert(id);
155    }
156    let cardinality = bitmap.len() as i64;
157
158    // Write Puffin .dvd file alongside table metadata.
159    let snap_id = new_snapshot_id();
160    let (puffin_bytes, blob_offset, blob_length) = PuffinWriter::write_single_dv(&bitmap, snap_id)?;
161    let table_root = meta.location.trim_end_matches('/');
162    let dv_path = format!("{table_root}/metadata/dv-{snap_id}.dvd");
163    store.put(&dv_path, puffin_bytes).await?;
164
165    // Patch the target entry with the new DV pointer.
166    files[target_idx].deletion_vector = Some(DeletionVector {
167        path: dv_path,
168        offset: blob_offset,
169        length: blob_length,
170        cardinality,
171    });
172
173    // Replace snapshot: carries all files with the updated DV entry.
174    // Replace does not inherit old manifests — the full file list is the new state.
175    let snapshot = NewSnapshot {
176        snapshot_id: snap_id,
177        parent_snapshot_id: meta.current_snapshot_id,
178        files,
179        operation: SnapshotOperation::Replace,
180        iceberg_schema: None,
181        extra_properties: std::collections::HashMap::new(),
182        bloom_filters: vec![],
183        equality_delete_files: vec![],
184    };
185    catalog.commit_snapshot(table, snapshot).await?;
186    Ok(())
187}
188
189// ── Equality Delete (Phase H) ──────────────────────────────────────────────────
190
191/// Logically delete all rows where `column_name` equals any value in `values`.
192///
193/// Writes an Iceberg equality delete Avro file containing one row per value,
194/// then commits a `Delete` snapshot that inherits existing data manifests and
195/// appends a new delete manifest (`content=1`) pointing to that file.
196///
197/// Scanners that load equality delete files (AI-Lake, Spark, Trino with plugin)
198/// will automatically mask matching rows at read time without rewriting data files.
199///
200/// # Arguments
201/// * `column_name` — column to match against (must exist in the table schema)
202/// * `values` — values that identify rows to delete
203pub async fn delete_where(
204    catalog: Arc<dyn CatalogProvider>,
205    store: Arc<dyn Store>,
206    table: &TableIdent,
207    column_name: &str,
208    values: &[&str],
209) -> AilakeResult<()> {
210    if values.is_empty() {
211        return Ok(());
212    }
213
214    let meta = catalog.load_table(table).await?;
215    let table_root = meta.location.trim_end_matches('/');
216
217    // Look up field-id and iceberg_type from schema_fields. Fall back to id=0 / "string"
218    // for tables without schema_fields (old format) — the column name in the Avro file
219    // is still sufficient for AI-Lake's own scanner.
220    let (field_id, iceberg_type) = meta
221        .schema_fields
222        .iter()
223        .find(|sf| sf.name == column_name)
224        .map(|sf| (sf.id, sf.iceberg_type.clone()))
225        .unwrap_or((0, "string".to_string()));
226
227    // Write equality delete Avro file.
228    let snap_id = new_snapshot_id();
229    let eq_del_avro =
230        ailake_catalog::write_equality_delete_avro(column_name, field_id, &iceberg_type, values)
231            .map_err(|e| AilakeError::Catalog(e.to_string()))?;
232    let file_size = eq_del_avro.len() as u64;
233    let eq_del_rel_path = format!("metadata/eq-del-{snap_id}.avro");
234    let eq_del_path = format!("{table_root}/{eq_del_rel_path}");
235    store.put(&eq_del_path, eq_del_avro).await?;
236
237    // `EqualityDeleteFile.path` must be relative to table_root — commit_snapshot's
238    // build_commit() (manifest_commit.rs) re-prefixes any non-absolute/non-URI path
239    // with table_root before writing the delete manifest. Passing the already-
240    // table_root-prefixed `eq_del_path` here double-prefixed it (table_root/table_root/
241    // metadata/...), so the delete manifest pointed at a file that never existed —
242    // list_equality_deletes' store.get() silently 404'd, the filter fell back to
243    // empty, and delete-where became a no-op: deleted rows kept appearing in every
244    // search(). Caught live while exercising `ailake delete-where` end-to-end.
245    let eq_del_file = ailake_catalog::EqualityDeleteFile {
246        path: eq_del_rel_path,
247        equality_ids: vec![field_id],
248        record_count: values.len() as u64,
249        file_size_bytes: file_size,
250        inline_values: Some((
251            column_name.to_string(),
252            values.iter().map(|v| v.to_string()).collect(),
253        )),
254        // Discarded on the write path — `write_equality_delete_manifest` (called via
255        // `commit_snapshot` below) stamps every entry with this commit's own `seq`.
256        sequence_number: 0,
257    };
258
259    // Commit Delete snapshot — inherits previous data manifests, appends delete manifest.
260    let snapshot = NewSnapshot {
261        snapshot_id: snap_id,
262        parent_snapshot_id: meta.current_snapshot_id,
263        files: vec![],
264        operation: SnapshotOperation::Delete,
265        iceberg_schema: None,
266        extra_properties: std::collections::HashMap::new(),
267        bloom_filters: vec![],
268        equality_delete_files: vec![eq_del_file],
269    };
270    catalog.commit_snapshot(table, snapshot).await?;
271    Ok(())
272}
273
274// ── Tests ─────────────────────────────────────────────────────────────────────
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use ailake_catalog::{
280        provider::{IndexStatus, TableProperties},
281        HadoopCatalog,
282    };
283    use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
284    use ailake_store::LocalStore;
285
286    fn make_props(format_version: u8) -> TableProperties {
287        TableProperties {
288            policy: VectorStoragePolicy {
289                column_name: "embedding".to_string(),
290                dim: 4,
291                metric: VectorMetric::Cosine,
292                precision: VectorPrecision::F16,
293                pq: None,
294                keep_raw_for_reranking: true,
295                pre_normalize: false,
296                hnsw_m: None,
297                hnsw_ef_construction: None,
298                ivf_residual: false,
299                embedding_model: None,
300                modality: None,
301                partition_by: None,
302                partition_value: None,
303                partition_column_type: None,
304                partition_fields: vec![],
305            },
306            extra: std::collections::HashMap::new(),
307            format_version,
308            partition_column_type: None,
309        }
310    }
311
312    fn make_file_entry(path: &str) -> DataFileEntry {
313        DataFileEntry {
314            path: path.to_string(),
315            record_count: 100,
316            file_size_bytes: 4096,
317            centroid_b64: None,
318            radius: None,
319            hnsw_offset: None,
320            hnsw_len: None,
321            vector_column: Some("embedding".to_string()),
322            vector_dim: Some(4),
323            extra_vector_indexes: vec![],
324            index_status: IndexStatus::Ready,
325            index_error: None,
326            batch_id: None,
327            embedding_model: None,
328            partition_value: None,
329            deletion_vector: None,
330            first_row_id: None,
331            column_stats: None,
332            sequence_number: 0,
333        }
334    }
335
336    async fn setup_v3_table(
337        warehouse: &str,
338        store: Arc<dyn Store>,
339    ) -> (Arc<dyn CatalogProvider>, TableIdent) {
340        let catalog: Arc<dyn CatalogProvider> =
341            Arc::new(HadoopCatalog::new(Arc::clone(&store), warehouse));
342        let table = TableIdent::new("default", "docs");
343        catalog.create_table(&table, &make_props(3)).await.unwrap();
344
345        let snap = NewSnapshot {
346            snapshot_id: new_snapshot_id(),
347            parent_snapshot_id: None,
348            files: vec![make_file_entry("data/part-00001.parquet")],
349            operation: SnapshotOperation::Append,
350            iceberg_schema: None,
351            extra_properties: std::collections::HashMap::new(),
352            bloom_filters: vec![],
353            equality_delete_files: vec![],
354        };
355        catalog.commit_snapshot(&table, snap).await.unwrap();
356        (catalog, table)
357    }
358
359    #[tokio::test]
360    async fn writes_dv_and_manifest_reflects_cardinality() {
361        let dir = tempfile::tempdir().unwrap();
362        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
363        let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
364
365        delete_rows(
366            Arc::clone(&catalog),
367            Arc::clone(&store),
368            &table,
369            "data/part-00001.parquet",
370            &[5, 10, 42],
371        )
372        .await
373        .unwrap();
374
375        let files = catalog.list_files(&table, None).await.unwrap();
376        assert_eq!(files.len(), 1);
377        let dv = files[0]
378            .deletion_vector
379            .as_ref()
380            .expect("DV should be present");
381        assert_eq!(dv.cardinality, 3);
382
383        // Verify Puffin file was created and bitmap is correct.
384        let bm = load_deletion_vector(&store, dv).await.unwrap();
385        assert!(bm.contains(5));
386        assert!(bm.contains(10));
387        assert!(bm.contains(42));
388        assert!(!bm.contains(0));
389        assert_eq!(bm.len(), 3);
390    }
391
392    #[tokio::test]
393    async fn merges_with_existing_dv_across_calls() {
394        let dir = tempfile::tempdir().unwrap();
395        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
396        let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
397
398        // First delete batch.
399        delete_rows(
400            Arc::clone(&catalog),
401            Arc::clone(&store),
402            &table,
403            "data/part-00001.parquet",
404            &[1, 2],
405        )
406        .await
407        .unwrap();
408
409        // Second delete batch — should accumulate.
410        delete_rows(
411            Arc::clone(&catalog),
412            Arc::clone(&store),
413            &table,
414            "data/part-00001.parquet",
415            &[3, 4],
416        )
417        .await
418        .unwrap();
419
420        let files = catalog.list_files(&table, None).await.unwrap();
421        let dv = files[0].deletion_vector.as_ref().unwrap();
422        let bm = load_deletion_vector(&store, dv).await.unwrap();
423        assert!(bm.contains(1) && bm.contains(2) && bm.contains(3) && bm.contains(4));
424        assert_eq!(bm.len(), 4);
425    }
426
427    #[tokio::test]
428    async fn delete_where_eq_delete_file_is_loadable_after_commit() {
429        // Regression test: delete_where() used to pass an already table_root-prefixed
430        // path into EqualityDeleteFile.path, which build_commit() (manifest_commit.rs)
431        // re-prefixed with table_root again — the delete manifest pointed at a file
432        // that never existed on disk. list_equality_deletes()'s store.get() 404'd,
433        // EqualityDeleteFilter::from_files() returned Err, and the caller (scanner.rs)
434        // silently fell back to an empty filter: delete-where became a no-op. This test
435        // exercises the real end-to-end path (write → commit → list → load), not just
436        // EqualityDeleteFilter in isolation with a hand-constructed relative path — the
437        // isolation tests already in this file passed both before and after the bug.
438        let dir = tempfile::tempdir().unwrap();
439        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
440        let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
441
442        delete_where(
443            Arc::clone(&catalog),
444            Arc::clone(&store),
445            &table,
446            "document_id",
447            &["doc-1", "doc-2"],
448        )
449        .await
450        .unwrap();
451
452        let edfs = catalog.list_equality_deletes(&table, None).await.unwrap();
453        assert_eq!(edfs.len(), 1, "expected exactly one equality delete file");
454
455        // This is the assertion that catches the regression: from_files() reads each
456        // edf.path via store.get() — it errors if the path was double-prefixed.
457        let filter = crate::equality_delete::EqualityDeleteFilter::from_files(&store, &edfs)
458            .await
459            .expect("equality delete file must be loadable from its committed path");
460        assert!(!filter.is_empty());
461
462        let batch = arrow_array::RecordBatch::try_new(
463            std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
464                "document_id",
465                arrow_schema::DataType::Utf8,
466                false,
467            )])),
468            vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
469                "doc-1", "doc-2", "doc-3",
470            ]))],
471        )
472        .unwrap();
473        // data_file_sequence_number=0 simulates a file committed before the delete
474        // (the delete's own real sequence number, read back via `edfs`, is 2 — see
475        // `setup_v3_table`'s Append at seq 1 followed by this `delete_where`'s Delete
476        // commit) — masking still applies for genuinely older files.
477        let filtered = filter.apply(batch, 0).unwrap();
478        assert_eq!(
479            filtered.num_rows(),
480            1,
481            "only doc-3 should survive the filter"
482        );
483        let ids = filtered
484            .column(0)
485            .as_any()
486            .downcast_ref::<arrow_array::StringArray>()
487            .unwrap();
488        assert_eq!(ids.value(0), "doc-3");
489    }
490
491    #[tokio::test]
492    async fn rejects_v2_table() {
493        let dir = tempfile::tempdir().unwrap();
494        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
495        let catalog: Arc<dyn CatalogProvider> =
496            Arc::new(HadoopCatalog::new(Arc::clone(&store), ""));
497        let table = TableIdent::new("default", "docs");
498        catalog.create_table(&table, &make_props(2)).await.unwrap();
499
500        let err = delete_rows(
501            Arc::clone(&catalog),
502            Arc::clone(&store),
503            &table,
504            "data/part-00001.parquet",
505            &[0],
506        )
507        .await
508        .unwrap_err();
509        assert!(err.to_string().contains("format-version=2"));
510    }
511
512    #[tokio::test]
513    async fn noop_when_row_ids_empty() {
514        let dir = tempfile::tempdir().unwrap();
515        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
516        let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
517
518        // Should return Ok immediately, no DV written.
519        delete_rows(
520            Arc::clone(&catalog),
521            Arc::clone(&store),
522            &table,
523            "data/part-00001.parquet",
524            &[],
525        )
526        .await
527        .unwrap();
528
529        let files = catalog.list_files(&table, None).await.unwrap();
530        assert!(files[0].deletion_vector.is_none());
531    }
532
533    #[tokio::test]
534    async fn puffin_magic_and_structure_valid() {
535        let mut bm = RoaringBitmap::new();
536        bm.insert(7);
537        bm.insert(99);
538        let (bytes, offset, length) = PuffinWriter::write_single_dv(&bm, 42).unwrap();
539
540        // Starts and ends with magic.
541        assert_eq!(&bytes[..4], PUFFIN_MAGIC);
542        assert_eq!(&bytes[bytes.len() - 4..], PUFFIN_MAGIC);
543
544        // Bitmap bytes are at the declared offset.
545        let blob_slice = &bytes[offset as usize..(offset + length) as usize];
546        let recovered = RoaringBitmap::deserialize_from(blob_slice).unwrap();
547        assert!(recovered.contains(7) && recovered.contains(99));
548    }
549}