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 inline_values: Some((
251 column_name.to_string(),
252 values.iter().map(|v| v.to_string()).collect(),
253 )),
254 sequence_number: 0,
257 };
258
259 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#[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 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 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 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 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 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 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 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 assert_eq!(&bytes[..4], PUFFIN_MAGIC);
542 assert_eq!(&bytes[bytes.len() - 4..], PUFFIN_MAGIC);
543
544 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}