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