Skip to main content

risingwave_connector/sink/iceberg/
position_delete.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Shared Iceberg position-delete (Parquet and Puffin deletion vector) helpers.
16
17use 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/// File-name generators shared by all Iceberg position-delete writers.
52///
53/// All writers use the same prefix and format-specific suffix pattern. The identity is the only
54/// caller-specific part and prevents concurrent actors/epochs from generating the same path.
55#[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
91/// Write one file-scoped position-delete artifact using the table's configured on-disk format.
92///
93/// All callers share this dispatch so Puffin deletion vectors and V2 Parquet position deletes use
94/// identical file-name and partition-path handling.
95pub 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
139/// Puffin blob property for deletion vector cardinality.
140const DELETION_VECTOR_PROPERTY_CARDINALITY: &str = "cardinality";
141/// Puffin blob property for referenced data file path.
142const DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE: &str = "referenced-data-file";
143
144/// Reads the deletion-vector positions of a single Puffin DV `DataFile`.
145pub 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
174/// Reads the positions stored in a V2 Parquet position-delete file into a [`DeleteVector`].
175///
176/// The file's schema is `(file_path, pos)`. Callers only invoke this after the entry's
177/// `referenced_data_file` already matched the target data file, and the files we write
178/// are file-scoped (every row shares one `file_path`), so the `file_path` column is
179/// redundant here: we project only the `pos` column and read every value.
180pub 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/// Checks required at the source boundary, but not by trusted sink read-modify-write calls.
190#[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    /// Trusted sink reads do not need an additional file-size request.
201    file_size: Option<u64>,
202    reader: PuffinReader,
203}
204
205/// Metadata-only source checks, shared by planning (including compaction) and replay reads.
206/// Returns the validated blob range for Puffin, or `None` for Parquet.
207pub(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
265/// Shared decoder with at most one cached Puffin reader/footer. Callers may reuse it across
266/// sequential tasks for the same table/FileIO. Decoded bitmaps are never cached by path.
267/// Puffin paths must identify immutable files; drop the reader to release its cache.
268pub 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    /// Strict source entry: validate descriptors, every Parquet path/position and Puffin metadata.
282    /// Reuse this reader across calls to retain its footer cache.
283    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            // Release the previous footer before opening a different artifact.
327            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            // Trusted sink files are file-scoped; preserve the position-only projection.
424            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        // Duplicates are harmless: Parquet record_count counts physical rows, not distinct positions.
477        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
487/// Reads the deleted positions of a single position-delete `DataFile` regardless of on-disk format,
488pub 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
505/// Writes `delete_vector` as a single Puffin deletion-vector blob referencing `data_file_path`,
506/// and returns its [`DataFile`] metadata (content `PositionDeletes`, format `Puffin`) with
507/// `referenced_data_file` set.
508pub 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
561/// How many positions to buffer before flushing one `(file_path, pos)` batch to the writer.
562const POSITION_DELETE_WRITE_CHUNK_SIZE: usize = 1024;
563
564/// Writes `delete_vector` as a single file-scoped Parquet position-delete file referencing
565/// `data_file_path`, and returns its [`DataFile`] metadata (content `PositionDeletes`, format
566/// `Parquet`) with `referenced_data_file` set.
567pub 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    // The position-delete schema is `(file_path, pos)` with reserved field IDs; derive the matching
593    // Arrow schema so the written column field IDs line up.
594    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    // `close` will yield exactly one builder here.
616    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    // `ParquetWriter` builds the file as `DataContentType::Data` with an empty partition; override
621    // those for a file-scoped V2 position-delete file and attach `referenced_data_file`.
622    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
635/// Writes one chunk of `positions` as a `(file_path, pos)` batch into `writer`. Every row shares
636/// `data_file_path` because the delete file is file-scoped.
637async 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}