1use 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
28const PUFFIN_MAGIC: &[u8] = b"PFAc";
32
33pub struct PuffinWriter;
42
43impl PuffinWriter {
44 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 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
86pub 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 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 let mut files: Vec<DataFileEntry> = catalog.list_files(table, None).await?;
138
139 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 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 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 files[target_idx].deletion_vector = Some(DeletionVector {
167 path: dv_path,
168 offset: blob_offset,
169 length: blob_length,
170 cardinality,
171 });
172
173 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
189pub 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 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 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 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 };
251
252 let snapshot = NewSnapshot {
254 snapshot_id: snap_id,
255 parent_snapshot_id: meta.current_snapshot_id,
256 files: vec![],
257 operation: SnapshotOperation::Delete,
258 iceberg_schema: None,
259 extra_properties: std::collections::HashMap::new(),
260 bloom_filters: vec![],
261 equality_delete_files: vec![eq_del_file],
262 };
263 catalog.commit_snapshot(table, snapshot).await?;
264 Ok(())
265}
266
267#[cfg(test)]
270mod tests {
271 use super::*;
272 use ailake_catalog::{
273 provider::{IndexStatus, TableProperties},
274 HadoopCatalog,
275 };
276 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
277 use ailake_store::LocalStore;
278
279 fn make_props(format_version: u8) -> TableProperties {
280 TableProperties {
281 policy: VectorStoragePolicy {
282 column_name: "embedding".to_string(),
283 dim: 4,
284 metric: VectorMetric::Cosine,
285 precision: VectorPrecision::F16,
286 pq: None,
287 keep_raw_for_reranking: true,
288 pre_normalize: false,
289 hnsw_m: None,
290 hnsw_ef_construction: None,
291 ivf_residual: false,
292 embedding_model: None,
293 modality: None,
294 partition_by: None,
295 partition_value: None,
296 partition_column_type: None,
297 partition_fields: vec![],
298 },
299 extra: std::collections::HashMap::new(),
300 format_version,
301 partition_column_type: None,
302 }
303 }
304
305 fn make_file_entry(path: &str) -> DataFileEntry {
306 DataFileEntry {
307 path: path.to_string(),
308 record_count: 100,
309 file_size_bytes: 4096,
310 centroid_b64: None,
311 radius: None,
312 hnsw_offset: None,
313 hnsw_len: None,
314 vector_column: Some("embedding".to_string()),
315 vector_dim: Some(4),
316 extra_vector_indexes: vec![],
317 index_status: IndexStatus::Ready,
318 index_error: None,
319 batch_id: None,
320 embedding_model: None,
321 partition_value: None,
322 deletion_vector: None,
323 first_row_id: None,
324 }
325 }
326
327 async fn setup_v3_table(
328 warehouse: &str,
329 store: Arc<dyn Store>,
330 ) -> (Arc<dyn CatalogProvider>, TableIdent) {
331 let catalog: Arc<dyn CatalogProvider> =
332 Arc::new(HadoopCatalog::new(Arc::clone(&store), warehouse));
333 let table = TableIdent::new("default", "docs");
334 catalog.create_table(&table, &make_props(3)).await.unwrap();
335
336 let snap = NewSnapshot {
337 snapshot_id: new_snapshot_id(),
338 parent_snapshot_id: None,
339 files: vec![make_file_entry("data/part-00001.parquet")],
340 operation: SnapshotOperation::Append,
341 iceberg_schema: None,
342 extra_properties: std::collections::HashMap::new(),
343 bloom_filters: vec![],
344 equality_delete_files: vec![],
345 };
346 catalog.commit_snapshot(&table, snap).await.unwrap();
347 (catalog, table)
348 }
349
350 #[tokio::test]
351 async fn writes_dv_and_manifest_reflects_cardinality() {
352 let dir = tempfile::tempdir().unwrap();
353 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
354 let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
355
356 delete_rows(
357 Arc::clone(&catalog),
358 Arc::clone(&store),
359 &table,
360 "data/part-00001.parquet",
361 &[5, 10, 42],
362 )
363 .await
364 .unwrap();
365
366 let files = catalog.list_files(&table, None).await.unwrap();
367 assert_eq!(files.len(), 1);
368 let dv = files[0]
369 .deletion_vector
370 .as_ref()
371 .expect("DV should be present");
372 assert_eq!(dv.cardinality, 3);
373
374 let bm = load_deletion_vector(&store, dv).await.unwrap();
376 assert!(bm.contains(5));
377 assert!(bm.contains(10));
378 assert!(bm.contains(42));
379 assert!(!bm.contains(0));
380 assert_eq!(bm.len(), 3);
381 }
382
383 #[tokio::test]
384 async fn merges_with_existing_dv_across_calls() {
385 let dir = tempfile::tempdir().unwrap();
386 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
387 let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
388
389 delete_rows(
391 Arc::clone(&catalog),
392 Arc::clone(&store),
393 &table,
394 "data/part-00001.parquet",
395 &[1, 2],
396 )
397 .await
398 .unwrap();
399
400 delete_rows(
402 Arc::clone(&catalog),
403 Arc::clone(&store),
404 &table,
405 "data/part-00001.parquet",
406 &[3, 4],
407 )
408 .await
409 .unwrap();
410
411 let files = catalog.list_files(&table, None).await.unwrap();
412 let dv = files[0].deletion_vector.as_ref().unwrap();
413 let bm = load_deletion_vector(&store, dv).await.unwrap();
414 assert!(bm.contains(1) && bm.contains(2) && bm.contains(3) && bm.contains(4));
415 assert_eq!(bm.len(), 4);
416 }
417
418 #[tokio::test]
419 async fn delete_where_eq_delete_file_is_loadable_after_commit() {
420 let dir = tempfile::tempdir().unwrap();
430 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
431 let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
432
433 delete_where(
434 Arc::clone(&catalog),
435 Arc::clone(&store),
436 &table,
437 "document_id",
438 &["doc-1", "doc-2"],
439 )
440 .await
441 .unwrap();
442
443 let edfs = catalog.list_equality_deletes(&table, None).await.unwrap();
444 assert_eq!(edfs.len(), 1, "expected exactly one equality delete file");
445
446 let filter = crate::equality_delete::EqualityDeleteFilter::from_files(&store, &edfs)
449 .await
450 .expect("equality delete file must be loadable from its committed path");
451 assert!(!filter.is_empty());
452
453 let batch = arrow_array::RecordBatch::try_new(
454 std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
455 "document_id",
456 arrow_schema::DataType::Utf8,
457 false,
458 )])),
459 vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
460 "doc-1", "doc-2", "doc-3",
461 ]))],
462 )
463 .unwrap();
464 let filtered = filter.apply(batch).unwrap();
465 assert_eq!(
466 filtered.num_rows(),
467 1,
468 "only doc-3 should survive the filter"
469 );
470 let ids = filtered
471 .column(0)
472 .as_any()
473 .downcast_ref::<arrow_array::StringArray>()
474 .unwrap();
475 assert_eq!(ids.value(0), "doc-3");
476 }
477
478 #[tokio::test]
479 async fn rejects_v2_table() {
480 let dir = tempfile::tempdir().unwrap();
481 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
482 let catalog: Arc<dyn CatalogProvider> =
483 Arc::new(HadoopCatalog::new(Arc::clone(&store), ""));
484 let table = TableIdent::new("default", "docs");
485 catalog.create_table(&table, &make_props(2)).await.unwrap();
486
487 let err = delete_rows(
488 Arc::clone(&catalog),
489 Arc::clone(&store),
490 &table,
491 "data/part-00001.parquet",
492 &[0],
493 )
494 .await
495 .unwrap_err();
496 assert!(err.to_string().contains("format-version=2"));
497 }
498
499 #[tokio::test]
500 async fn noop_when_row_ids_empty() {
501 let dir = tempfile::tempdir().unwrap();
502 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
503 let (catalog, table) = setup_v3_table("", Arc::clone(&store)).await;
504
505 delete_rows(
507 Arc::clone(&catalog),
508 Arc::clone(&store),
509 &table,
510 "data/part-00001.parquet",
511 &[],
512 )
513 .await
514 .unwrap();
515
516 let files = catalog.list_files(&table, None).await.unwrap();
517 assert!(files[0].deletion_vector.is_none());
518 }
519
520 #[tokio::test]
521 async fn puffin_magic_and_structure_valid() {
522 let mut bm = RoaringBitmap::new();
523 bm.insert(7);
524 bm.insert(99);
525 let (bytes, offset, length) = PuffinWriter::write_single_dv(&bm, 42).unwrap();
526
527 assert_eq!(&bytes[..4], PUFFIN_MAGIC);
529 assert_eq!(&bytes[bytes.len() - 4..], PUFFIN_MAGIC);
530
531 let blob_slice = &bytes[offset as usize..(offset + length) as usize];
533 let recovered = RoaringBitmap::deserialize_from(blob_slice).unwrap();
534 assert!(recovered.contains(7) && recovered.contains(99));
535 }
536}