1use std::collections::HashMap;
18use std::fmt::Display;
19use std::sync::Arc;
20
21use anyhow::{Context, Result, anyhow, bail, ensure};
22use futures::StreamExt;
23use iceberg::arrow::schema_to_arrow_schema;
24use iceberg::delete_vector::DeleteVector;
25use iceberg::io::FileIO;
26use iceberg::puffin::{CompressionCodec, PuffinReader, PuffinWriter};
27use iceberg::scan::FileScanTaskDeleteFile;
28use iceberg::spec::{
29 DataContentType, DataFile, DataFileBuilder, DataFileFormat, FormatVersion, PartitionKey,
30};
31use iceberg::table::Table;
32use iceberg::writer::base_writer::position_delete_file_writer::POSITION_DELETE_SCHEMA;
33use iceberg::writer::file_writer::location_generator::{
34 DefaultFileNameGenerator, DefaultLocationGenerator, FileNameGenerator, LocationGenerator,
35};
36use iceberg::writer::file_writer::{
37 FileWriter, FileWriterBuilder, ParquetWriter, ParquetWriterBuilder,
38};
39use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask};
40use parquet::file::properties::WriterProperties;
41use risingwave_common::array::arrow::arrow_array_iceberg::{
42 Array, ArrayRef, Int64Array, RecordBatch, StringArray,
43};
44use risingwave_common::array::arrow::arrow_schema_iceberg::{
45 DataType as ArrowDataType, SchemaRef as ArrowSchemaRef,
46};
47
48use crate::sink::iceberg::{IcebergConfig, PARQUET_CREATED_BY};
49use crate::source::iceberg::parquet_file_handler::ParquetFileReader;
50
51#[derive(Clone, Debug)]
56pub struct PositionDeleteFileNameGenerators {
57 pub puffin: DefaultFileNameGenerator,
58 pub parquet: DefaultFileNameGenerator,
59}
60
61impl PositionDeleteFileNameGenerators {
62 pub fn new(identity: impl Display) -> Self {
63 let prefix = "position-delete".to_owned();
64 let unique_suffix = identity.to_string();
65 Self {
66 puffin: DefaultFileNameGenerator::new(
67 prefix.clone(),
68 Some(unique_suffix.clone()),
69 DataFileFormat::Puffin,
70 ),
71 parquet: DefaultFileNameGenerator::new(
72 prefix,
73 Some(unique_suffix),
74 DataFileFormat::Parquet,
75 ),
76 }
77 }
78
79 pub fn for_format(&self, format: DataFileFormat) -> anyhow::Result<&DefaultFileNameGenerator> {
80 match format {
81 DataFileFormat::Puffin => Ok(&self.puffin),
82 DataFileFormat::Parquet => Ok(&self.parquet),
83 other => anyhow::bail!(
84 "unsupported position-delete output format {:?}; expected Puffin or Parquet",
85 other
86 ),
87 }
88 }
89}
90
91pub async fn write_position_delete_file(
96 table: &Table,
97 config: &IcebergConfig,
98 location_generator: &DefaultLocationGenerator,
99 file_name_generators: &PositionDeleteFileNameGenerators,
100 format_version: FormatVersion,
101 data_file_path: String,
102 delete_vector: &DeleteVector,
103 partition_key: Option<&PartitionKey>,
104) -> Result<DataFile> {
105 let format = if format_version >= FormatVersion::V3 {
106 DataFileFormat::Puffin
107 } else {
108 DataFileFormat::Parquet
109 };
110 let file_name_generator = file_name_generators.for_format(format)?;
111 match format {
112 DataFileFormat::Puffin => {
113 write_dv_puffin_file(
114 table,
115 location_generator,
116 file_name_generator,
117 data_file_path,
118 delete_vector,
119 partition_key,
120 )
121 .await
122 }
123 DataFileFormat::Parquet => {
124 write_parquet_position_delete_file(
125 table,
126 location_generator,
127 file_name_generator,
128 config,
129 data_file_path,
130 delete_vector,
131 partition_key,
132 )
133 .await
134 }
135 _ => unreachable!("position-delete format is selected above"),
136 }
137}
138
139const DELETION_VECTOR_PROPERTY_CARDINALITY: &str = "cardinality";
141const DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE: &str = "referenced-data-file";
143
144pub async fn read_dv_positions_from_data_file(
146 file_io: &FileIO,
147 data_file: &DataFile,
148) -> Result<DeleteVector> {
149 let blob_offset = data_file.content_offset().with_context(|| {
150 format!(
151 "DV file {} missing content_offset for referenced data file {:?}",
152 data_file.file_path(),
153 data_file.referenced_data_file()
154 )
155 })?;
156 let blob_length = data_file.content_size_in_bytes().with_context(|| {
157 format!(
158 "DV file {} missing content_size_in_bytes for referenced data file {:?}",
159 data_file.file_path(),
160 data_file.referenced_data_file()
161 )
162 })?;
163
164 PositionDeleteReader::new(file_io)
165 .read_dv(
166 data_file.file_path(),
167 blob_offset as u64,
168 blob_length as u64,
169 None,
170 )
171 .await
172}
173
174pub async fn read_parquet_position_deletes_from_file(
181 file_io: &FileIO,
182 delete_file: &DataFile,
183) -> Result<DeleteVector> {
184 PositionDeleteReader::new(file_io)
185 .read_parquet(delete_file.file_path(), None)
186 .await
187}
188
189#[derive(Clone, Copy)]
191struct PositionDeleteValidation<'a> {
192 data_file_path: &'a str,
193 data_record_count: u64,
194 file_size: u64,
195 record_count: u64,
196}
197
198struct CachedPuffinReader {
199 path: String,
200 file_size: Option<u64>,
202 reader: PuffinReader,
203}
204
205pub(crate) fn validate_position_delete_descriptor(
208 delete: &FileScanTaskDeleteFile,
209 data_file_path: &str,
210) -> Result<Option<(u64, u64)>> {
211 ensure!(
212 delete.file_type == DataContentType::PositionDeletes,
213 "expected file-scoped position deletes"
214 );
215 ensure!(
216 delete.equality_ids.is_none(),
217 "equality IDs on a position-delete artifact"
218 );
219 ensure!(
220 delete.key_metadata.is_none(),
221 "encrypted delete artifacts are not supported by the update reader"
222 );
223 ensure!(
224 delete.referenced_data_file.as_deref() == Some(data_file_path),
225 "delete artifact references a different or unspecified data file"
226 );
227 ensure!(
228 delete.record_count.is_some(),
229 "delete artifact is missing record_count"
230 );
231 match delete.file_format {
232 DataFileFormat::Puffin => {
233 let offset = u64::try_from(
234 delete
235 .content_offset
236 .context("DV is missing content_offset")?,
237 )
238 .context("negative DV offset")?;
239 let length = u64::try_from(
240 delete
241 .content_size_in_bytes
242 .context("DV is missing content_size_in_bytes")?,
243 )
244 .context("negative DV length")?;
245 ensure!(
246 length > 0
247 && offset
248 .checked_add(length)
249 .is_some_and(|end| end <= delete.file_size_in_bytes),
250 "DV range is outside its Puffin file"
251 );
252 Ok(Some((offset, length)))
253 }
254 DataFileFormat::Parquet => {
255 ensure!(
256 delete.content_offset.is_none() && delete.content_size_in_bytes.is_none(),
257 "Parquet position deletes must not specify a DV range"
258 );
259 Ok(None)
260 }
261 _ => anyhow::bail!("unsupported position-delete format"),
262 }
263}
264
265pub struct PositionDeleteReader {
269 file_io: FileIO,
270 puffin: Option<CachedPuffinReader>,
271}
272
273impl PositionDeleteReader {
274 pub fn new(file_io: &FileIO) -> Self {
275 Self {
276 file_io: file_io.clone(),
277 puffin: None,
278 }
279 }
280
281 pub(crate) async fn read_file_scoped(
284 &mut self,
285 data_file_path: &str,
286 data_record_count: u64,
287 deletes: &[FileScanTaskDeleteFile],
288 ) -> Result<DeleteVector> {
289 ensure!(
290 deletes.len() <= 1,
291 "multiple live delete artifacts for one data file"
292 );
293 let Some(delete) = deletes.first() else {
294 return Ok(DeleteVector::default());
295 };
296 let blob_range = validate_position_delete_descriptor(delete, data_file_path)?;
297 let validation = PositionDeleteValidation {
298 data_file_path,
299 data_record_count,
300 file_size: delete.file_size_in_bytes,
301 record_count: delete
302 .record_count
303 .context("delete artifact is missing record_count")?,
304 };
305 match blob_range {
306 Some((offset, length)) => {
307 self.read_dv(&delete.file_path, offset, length, Some(validation))
308 .await
309 }
310 None => self.read_parquet(&delete.file_path, Some(validation)).await,
311 }
312 }
313
314 async fn read_dv(
315 &mut self,
316 file_path: &str,
317 offset: u64,
318 length: u64,
319 validation: Option<PositionDeleteValidation<'_>>,
320 ) -> Result<DeleteVector> {
321 if self
322 .puffin
323 .as_ref()
324 .is_none_or(|cached| cached.path != file_path)
325 {
326 self.puffin = None;
328 let input = self.file_io.new_input(file_path)?;
329 self.puffin = Some(CachedPuffinReader {
330 path: file_path.to_owned(),
331 file_size: None,
332 reader: PuffinReader::new(input).await?,
333 });
334 }
335 let cached = self
336 .puffin
337 .as_mut()
338 .expect("Puffin reader initialized above");
339 if let Some(validation) = validation {
340 let size = match cached.file_size {
341 Some(size) => size,
342 None => {
343 let size = self.file_io.new_input(file_path)?.metadata().await?.size;
344 cached.file_size = Some(size);
345 size
346 }
347 };
348 ensure!(
349 size == validation.file_size,
350 "delete artifact size differs from its descriptor"
351 );
352 }
353 let metadata = cached.reader.file_metadata().await?;
354 let blob_metadata = metadata
355 .blobs()
356 .iter()
357 .find(|blob| blob.offset() == offset && blob.length() == length)
358 .context("DV blob range is missing from the Puffin footer")?;
359 let blob = cached.reader.blob(blob_metadata).await?;
360 if let Some(validation) = validation {
361 ensure!(
362 blob.properties()
363 .get(DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE)
364 .map(String::as_str)
365 == Some(validation.data_file_path),
366 "DV blob references a different or unspecified data file"
367 );
368 let cardinality: u64 = blob
369 .properties()
370 .get(DELETION_VECTOR_PROPERTY_CARDINALITY)
371 .context("DV blob is missing cardinality")?
372 .parse()
373 .context("invalid DV cardinality")?;
374 ensure!(
375 cardinality == validation.record_count,
376 "DV cardinality differs from its descriptor"
377 );
378 }
379 let positions = DeleteVector::from_puffin_blob(blob)?;
380 if let Some(validation) = validation {
381 ensure!(
382 positions.len() == validation.record_count,
383 "DV cardinality differs from its bitmap"
384 );
385 ensure!(
386 positions
387 .max()
388 .is_none_or(|pos| pos < validation.data_record_count),
389 "deleted position is outside the data file"
390 );
391 }
392 Ok(positions)
393 }
394
395 async fn read_parquet(
396 &self,
397 file_path: &str,
398 validation: Option<PositionDeleteValidation<'_>>,
399 ) -> Result<DeleteVector> {
400 let input = self.file_io.new_input(file_path)?;
401 let metadata = input.metadata().await?;
402 if let Some(validation) = validation {
403 ensure!(
404 metadata.size == validation.file_size,
405 "delete artifact size differs from its descriptor"
406 );
407 }
408 let reader = ParquetFileReader::new(metadata, input.reader().await?);
409 let builder = ParquetRecordBatchStreamBuilder::new(reader).await?;
410 let builder = if validation.is_some() {
411 ensure!(
412 builder.schema().fields().len() == 2,
413 "invalid position-delete schema"
414 );
415 ensure!(
416 builder.schema().field_with_name("file_path")?.data_type() == &ArrowDataType::Utf8
417 && builder.schema().field_with_name("pos")?.data_type()
418 == &ArrowDataType::Int64,
419 "invalid position-delete column types"
420 );
421 builder.with_batch_size(1024)
422 } else {
423 let projection = ProjectionMask::leaves(builder.parquet_schema(), [1]);
425 builder.with_projection(projection)
426 };
427 let mut batches = builder.build()?;
428 let mut positions = DeleteVector::default();
429 let mut rows = 0u64;
430 while let Some(batch) = batches.next().await {
431 let batch = batch?;
432 let (paths, values) = if let Some(validation) = validation {
433 let paths = batch
434 .column_by_name("file_path")
435 .and_then(|col| col.as_any().downcast_ref::<StringArray>())
436 .context("position-delete file_path must be a string")?;
437 let values = batch
438 .column_by_name("pos")
439 .context("missing position-delete pos column")?;
440 (Some((paths, validation.data_file_path)), values)
441 } else {
442 (None, batch.column(0))
443 };
444 let values = values
445 .as_any()
446 .downcast_ref::<Int64Array>()
447 .context("position-delete pos must be an int64")?;
448 for (index, pos) in values.iter().enumerate() {
449 if let Some((paths, data_file_path)) = paths {
450 ensure!(
451 !paths.is_null(index) && paths.value(index) == data_file_path,
452 "position-delete row references a different or null data file"
453 );
454 }
455 let pos = pos.context("null deleted position")?;
456 let pos = if let Some(validation) = validation {
457 let pos = u64::try_from(pos).context("negative deleted position")?;
458 ensure!(
459 pos < validation.data_record_count,
460 "deleted position is outside the data file"
461 );
462 pos
463 } else {
464 pos as u64
465 };
466 positions.insert(pos);
467 }
468 if let Some(validation) = validation {
469 rows += batch.num_rows() as u64;
470 ensure!(
471 rows <= validation.record_count,
472 "position-delete row count exceeds its descriptor"
473 );
474 }
475 }
476 if let Some(validation) = validation {
478 ensure!(
479 rows == validation.record_count,
480 "position-delete row count differs from its descriptor"
481 );
482 }
483 Ok(positions)
484 }
485}
486
487pub async fn read_position_deletes_from_file(
489 file_io: &FileIO,
490 delete_file: &DataFile,
491) -> Result<DeleteVector> {
492 match delete_file.file_format() {
493 DataFileFormat::Puffin => read_dv_positions_from_data_file(file_io, delete_file).await,
494 DataFileFormat::Parquet => {
495 read_parquet_position_deletes_from_file(file_io, delete_file).await
496 }
497 other => bail!(
498 "position-delete file {} has unsupported format {:?}; expected Puffin or Parquet",
499 delete_file.file_path(),
500 other
501 ),
502 }
503}
504
505pub async fn write_dv_puffin_file(
509 table: &Table,
510 location_generator: &DefaultLocationGenerator,
511 file_name_generator: &DefaultFileNameGenerator,
512 data_file_path: String,
513 delete_vector: &DeleteVector,
514 partition_key: Option<&PartitionKey>,
515) -> Result<DataFile> {
516 let file_name = file_name_generator.generate_file_name();
517 let location = location_generator.generate_location(partition_key, &file_name);
518 let output_file = table.file_io().new_output(&location)?;
519 let mut writer = PuffinWriter::new(&output_file, HashMap::new(), false).await?;
520
521 let cardinality = delete_vector.len();
522 let properties = HashMap::from([
523 (
524 DELETION_VECTOR_PROPERTY_CARDINALITY.to_owned(),
525 cardinality.to_string(),
526 ),
527 (
528 DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE.to_owned(),
529 data_file_path.clone(),
530 ),
531 ]);
532 let blob = delete_vector.to_puffin_blob(properties)?;
533 writer.add(blob, CompressionCodec::None).await?;
534
535 let result = writer.close_with_metadata().await?;
536 let blob_metadata = result
537 .blobs_metadata
538 .first()
539 .context("blob metadata should be present")?;
540
541 let mut builder = DataFileBuilder::default();
542 builder
543 .content(DataContentType::PositionDeletes)
544 .file_path(location)
545 .file_format(DataFileFormat::Puffin)
546 .record_count(cardinality)
547 .file_size_in_bytes(result.file_size_in_bytes)
548 .referenced_data_file(Some(data_file_path))
549 .content_offset(Some(blob_metadata.offset() as i64))
550 .content_size_in_bytes(Some(blob_metadata.length() as i64));
551 if let Some(partition_key) = partition_key {
552 builder
553 .partition(partition_key.data().clone())
554 .partition_spec_id(partition_key.spec().spec_id());
555 }
556 builder
557 .build()
558 .context("Failed to build deletion vector file metadata")
559}
560
561const POSITION_DELETE_WRITE_CHUNK_SIZE: usize = 1024;
563
564pub async fn write_parquet_position_delete_file(
568 table: &Table,
569 location_generator: &DefaultLocationGenerator,
570 file_name_generator: &DefaultFileNameGenerator,
571 config: &IcebergConfig,
572 data_file_path: String,
573 delete_vector: &DeleteVector,
574 partition_key: Option<&PartitionKey>,
575) -> Result<DataFile> {
576 let file_name = file_name_generator.generate_file_name();
577 let location = location_generator.generate_location(partition_key, &file_name);
578 let output_file = table.file_io().new_output(&location)?;
579
580 let parquet_writer_properties = WriterProperties::builder()
581 .set_compression(config.get_parquet_compression())
582 .set_max_row_group_bytes(config.write_parquet_max_row_group_bytes())
583 .set_created_by(PARQUET_CREATED_BY.to_owned())
584 .build();
585 let mut writer = ParquetWriterBuilder::new(
586 parquet_writer_properties,
587 POSITION_DELETE_SCHEMA.clone().into(),
588 )
589 .build(output_file)
590 .await?;
591
592 let arrow_schema: ArrowSchemaRef = Arc::new(schema_to_arrow_schema(&POSITION_DELETE_SCHEMA)?);
595
596 let mut positions: Vec<i64> = Vec::with_capacity(POSITION_DELETE_WRITE_CHUNK_SIZE);
597 for pos in delete_vector.iter() {
598 positions.push(pos as i64);
599 if positions.len() == POSITION_DELETE_WRITE_CHUNK_SIZE {
600 write_position_delete_chunk(
601 &mut writer,
602 &arrow_schema,
603 &data_file_path,
604 std::mem::take(&mut positions),
605 )
606 .await?;
607 positions.reserve(POSITION_DELETE_WRITE_CHUNK_SIZE);
608 }
609 }
610 if !positions.is_empty() {
611 write_position_delete_chunk(&mut writer, &arrow_schema, &data_file_path, positions).await?;
612 }
613
614 let data_files = writer.close().await?;
615 let [mut builder] = data_files.try_into().map_err(|_| {
617 anyhow!("position-delete writer produced invalid file count for {data_file_path}")
618 })?;
619
620 builder
623 .content(DataContentType::PositionDeletes)
624 .referenced_data_file(Some(data_file_path));
625 if let Some(partition_key) = partition_key {
626 builder
627 .partition(partition_key.data().clone())
628 .partition_spec_id(partition_key.spec().spec_id());
629 }
630 builder
631 .build()
632 .context("Failed to build position-delete file metadata")
633}
634
635async fn write_position_delete_chunk(
638 writer: &mut ParquetWriter,
639 arrow_schema: &ArrowSchemaRef,
640 data_file_path: &str,
641 positions: Vec<i64>,
642) -> Result<()> {
643 let path_column: ArrayRef = Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
644 data_file_path,
645 positions.len(),
646 )));
647 let pos_column: ArrayRef = Arc::new(Int64Array::from(positions));
648 let batch = RecordBatch::try_new(arrow_schema.clone(), vec![path_column, pos_column])
649 .map_err(|e| anyhow!(e))?;
650 writer.write(&batch).await?;
651 Ok(())
652}