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 (Puffin deletion vector) helpers.
16
17use std::collections::HashMap;
18use std::fmt::Display;
19use std::sync::Arc;
20
21use anyhow::{Context, Result, anyhow, bail};
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::spec::{
28    DataContentType, DataFile, DataFileBuilder, DataFileFormat, FormatVersion, PartitionKey,
29};
30use iceberg::table::Table;
31use iceberg::writer::base_writer::position_delete_file_writer::POSITION_DELETE_SCHEMA;
32use iceberg::writer::file_writer::location_generator::{
33    DefaultFileNameGenerator, DefaultLocationGenerator, FileNameGenerator, LocationGenerator,
34};
35use iceberg::writer::file_writer::{
36    FileWriter, FileWriterBuilder, ParquetWriter, ParquetWriterBuilder,
37};
38use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask};
39use parquet::file::properties::WriterProperties;
40use risingwave_common::array::arrow::arrow_array_iceberg::{
41    Array, ArrayRef, Int64Array, RecordBatch, StringArray,
42};
43use risingwave_common::array::arrow::arrow_schema_iceberg::SchemaRef as ArrowSchemaRef;
44
45use crate::sink::iceberg::{IcebergConfig, PARQUET_CREATED_BY};
46use crate::source::iceberg::parquet_file_handler::ParquetFileReader;
47
48/// File-name generators shared by all Iceberg position-delete writers.
49///
50/// All writers use the same prefix and format-specific suffix pattern. The identity is the only
51/// caller-specific part and prevents concurrent actors/epochs from generating the same path.
52#[derive(Clone, Debug)]
53pub struct PositionDeleteFileNameGenerators {
54    pub puffin: DefaultFileNameGenerator,
55    pub parquet: DefaultFileNameGenerator,
56}
57
58impl PositionDeleteFileNameGenerators {
59    pub fn new(identity: impl Display) -> Self {
60        let prefix = "position-delete".to_owned();
61        let unique_suffix = identity.to_string();
62        Self {
63            puffin: DefaultFileNameGenerator::new(
64                prefix.clone(),
65                Some(unique_suffix.clone()),
66                DataFileFormat::Puffin,
67            ),
68            parquet: DefaultFileNameGenerator::new(
69                prefix,
70                Some(unique_suffix),
71                DataFileFormat::Parquet,
72            ),
73        }
74    }
75
76    pub fn for_format(&self, format: DataFileFormat) -> anyhow::Result<&DefaultFileNameGenerator> {
77        match format {
78            DataFileFormat::Puffin => Ok(&self.puffin),
79            DataFileFormat::Parquet => Ok(&self.parquet),
80            other => anyhow::bail!(
81                "unsupported position-delete output format {:?}; expected Puffin or Parquet",
82                other
83            ),
84        }
85    }
86}
87
88/// Write one file-scoped position-delete artifact using the table's configured on-disk format.
89///
90/// All callers share this dispatch so Puffin deletion vectors and V2 Parquet position deletes use
91/// identical file-name and partition-path handling.
92pub async fn write_position_delete_file(
93    table: &Table,
94    config: &IcebergConfig,
95    location_generator: &DefaultLocationGenerator,
96    file_name_generators: &PositionDeleteFileNameGenerators,
97    format_version: FormatVersion,
98    data_file_path: String,
99    delete_vector: &DeleteVector,
100    partition_key: Option<&PartitionKey>,
101) -> Result<DataFile> {
102    let format = if format_version >= FormatVersion::V3 {
103        DataFileFormat::Puffin
104    } else {
105        DataFileFormat::Parquet
106    };
107    let file_name_generator = file_name_generators.for_format(format)?;
108    match format {
109        DataFileFormat::Puffin => {
110            write_dv_puffin_file(
111                table,
112                location_generator,
113                file_name_generator,
114                data_file_path,
115                delete_vector,
116                partition_key,
117            )
118            .await
119        }
120        DataFileFormat::Parquet => {
121            write_parquet_position_delete_file(
122                table,
123                location_generator,
124                file_name_generator,
125                config,
126                data_file_path,
127                delete_vector,
128                partition_key,
129            )
130            .await
131        }
132        _ => unreachable!("position-delete format is selected above"),
133    }
134}
135
136/// Puffin blob property for deletion vector cardinality.
137const DELETION_VECTOR_PROPERTY_CARDINALITY: &str = "cardinality";
138/// Puffin blob property for referenced data file path.
139const DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE: &str = "referenced-data-file";
140
141/// Reads the deletion-vector positions of a single Puffin DV `DataFile`.
142pub async fn read_dv_positions_from_data_file(
143    file_io: &FileIO,
144    data_file: &DataFile,
145) -> Result<DeleteVector> {
146    let blob_offset = data_file.content_offset().with_context(|| {
147        format!(
148            "DV file {} missing content_offset for referenced data file {:?}",
149            data_file.file_path(),
150            data_file.referenced_data_file()
151        )
152    })?;
153    let blob_length = data_file.content_size_in_bytes().with_context(|| {
154        format!(
155            "DV file {} missing content_size_in_bytes for referenced data file {:?}",
156            data_file.file_path(),
157            data_file.referenced_data_file()
158        )
159    })?;
160
161    let input_file = file_io.new_input(data_file.file_path())?;
162    let puffin_reader = PuffinReader::new(input_file).await?;
163    let file_metadata = puffin_reader.file_metadata().await?;
164    let blob_metadata = file_metadata
165        .blobs()
166        .iter()
167        .find(|blob| blob.offset() == blob_offset as u64 && blob.length() == blob_length as u64)
168        .with_context(|| {
169            format!(
170                "DV blob metadata not found in {} at offset={} length={}",
171                data_file.file_path(),
172                blob_offset,
173                blob_length
174            )
175        })?;
176    let blob = puffin_reader.blob(blob_metadata).await?;
177
178    let delete_vector = DeleteVector::from_puffin_blob(blob)?;
179    Ok(delete_vector)
180}
181
182/// Reads the positions stored in a V2 Parquet position-delete file into a [`DeleteVector`].
183///
184/// The file's schema is `(file_path, pos)`. Callers only invoke this after the entry's
185/// `referenced_data_file` already matched the target data file, and the files we write
186/// are file-scoped (every row shares one `file_path`), so the `file_path` column is
187/// redundant here: we project only the `pos` column and read every value.
188pub async fn read_parquet_position_deletes_from_file(
189    file_io: &FileIO,
190    delete_file: &DataFile,
191) -> Result<DeleteVector> {
192    let input_file = file_io.new_input(delete_file.file_path())?;
193    let metadata = input_file.metadata().await?;
194    let reader = input_file.reader().await?;
195    let parquet_reader = ParquetFileReader::new(metadata, reader);
196    let builder = ParquetRecordBatchStreamBuilder::new(parquet_reader).await?;
197    // Project only the `pos` leaf (column index 1) so the `file_path` column is never decoded.
198    let projection = ProjectionMask::leaves(builder.parquet_schema(), [1]);
199    let mut stream = builder.with_projection(projection).build()?;
200
201    let mut delete_vector = DeleteVector::default();
202    while let Some(batch) = stream.next().await {
203        let batch = batch?;
204        // Only the projected `pos` column is present in the batch.
205        let positions = batch.columns()[0]
206            .as_any()
207            .downcast_ref::<Int64Array>()
208            .context("position-delete pos column should be an Int64Array")?;
209        for pos in positions {
210            let pos = pos.with_context(|| {
211                format!(
212                    "null value in position-delete file {}",
213                    delete_file.file_path()
214                )
215            })?;
216            delete_vector.insert(pos as u64);
217        }
218    }
219
220    Ok(delete_vector)
221}
222
223/// Reads the deleted positions of a single position-delete `DataFile` regardless of on-disk format,
224pub async fn read_position_deletes_from_file(
225    file_io: &FileIO,
226    delete_file: &DataFile,
227) -> Result<DeleteVector> {
228    match delete_file.file_format() {
229        DataFileFormat::Puffin => read_dv_positions_from_data_file(file_io, delete_file).await,
230        DataFileFormat::Parquet => {
231            read_parquet_position_deletes_from_file(file_io, delete_file).await
232        }
233        other => bail!(
234            "position-delete file {} has unsupported format {:?}; expected Puffin or Parquet",
235            delete_file.file_path(),
236            other
237        ),
238    }
239}
240
241/// Writes `delete_vector` as a single Puffin deletion-vector blob referencing `data_file_path`,
242/// and returns its [`DataFile`] metadata (content `PositionDeletes`, format `Puffin`) with
243/// `referenced_data_file` set.
244pub async fn write_dv_puffin_file(
245    table: &Table,
246    location_generator: &DefaultLocationGenerator,
247    file_name_generator: &DefaultFileNameGenerator,
248    data_file_path: String,
249    delete_vector: &DeleteVector,
250    partition_key: Option<&PartitionKey>,
251) -> Result<DataFile> {
252    let file_name = file_name_generator.generate_file_name();
253    let location = location_generator.generate_location(partition_key, &file_name);
254    let output_file = table.file_io().new_output(&location)?;
255    let mut writer = PuffinWriter::new(&output_file, HashMap::new(), false).await?;
256
257    let cardinality = delete_vector.len();
258    let properties = HashMap::from([
259        (
260            DELETION_VECTOR_PROPERTY_CARDINALITY.to_owned(),
261            cardinality.to_string(),
262        ),
263        (
264            DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE.to_owned(),
265            data_file_path.clone(),
266        ),
267    ]);
268    let blob = delete_vector.to_puffin_blob(properties)?;
269    writer.add(blob, CompressionCodec::None).await?;
270
271    let result = writer.close_with_metadata().await?;
272    let blob_metadata = result
273        .blobs_metadata
274        .first()
275        .context("blob metadata should be present")?;
276
277    let mut builder = DataFileBuilder::default();
278    builder
279        .content(DataContentType::PositionDeletes)
280        .file_path(location)
281        .file_format(DataFileFormat::Puffin)
282        .record_count(cardinality)
283        .file_size_in_bytes(result.file_size_in_bytes)
284        .referenced_data_file(Some(data_file_path))
285        .content_offset(Some(blob_metadata.offset() as i64))
286        .content_size_in_bytes(Some(blob_metadata.length() as i64));
287    if let Some(partition_key) = partition_key {
288        builder
289            .partition(partition_key.data().clone())
290            .partition_spec_id(partition_key.spec().spec_id());
291    }
292    builder
293        .build()
294        .context("Failed to build deletion vector file metadata")
295}
296
297/// How many positions to buffer before flushing one `(file_path, pos)` batch to the writer.
298const POSITION_DELETE_WRITE_CHUNK_SIZE: usize = 1024;
299
300/// Writes `delete_vector` as a single file-scoped Parquet position-delete file referencing
301/// `data_file_path`, and returns its [`DataFile`] metadata (content `PositionDeletes`, format
302/// `Parquet`) with `referenced_data_file` set.
303pub async fn write_parquet_position_delete_file(
304    table: &Table,
305    location_generator: &DefaultLocationGenerator,
306    file_name_generator: &DefaultFileNameGenerator,
307    config: &IcebergConfig,
308    data_file_path: String,
309    delete_vector: &DeleteVector,
310    partition_key: Option<&PartitionKey>,
311) -> Result<DataFile> {
312    let file_name = file_name_generator.generate_file_name();
313    let location = location_generator.generate_location(partition_key, &file_name);
314    let output_file = table.file_io().new_output(&location)?;
315
316    let parquet_writer_properties = WriterProperties::builder()
317        .set_compression(config.get_parquet_compression())
318        .set_max_row_group_bytes(config.write_parquet_max_row_group_bytes())
319        .set_created_by(PARQUET_CREATED_BY.to_owned())
320        .build();
321    let mut writer = ParquetWriterBuilder::new(
322        parquet_writer_properties,
323        POSITION_DELETE_SCHEMA.clone().into(),
324    )
325    .build(output_file)
326    .await?;
327
328    // The position-delete schema is `(file_path, pos)` with reserved field IDs; derive the matching
329    // Arrow schema so the written column field IDs line up.
330    let arrow_schema: ArrowSchemaRef = Arc::new(schema_to_arrow_schema(&POSITION_DELETE_SCHEMA)?);
331
332    let mut positions: Vec<i64> = Vec::with_capacity(POSITION_DELETE_WRITE_CHUNK_SIZE);
333    for pos in delete_vector.iter() {
334        positions.push(pos as i64);
335        if positions.len() == POSITION_DELETE_WRITE_CHUNK_SIZE {
336            write_position_delete_chunk(
337                &mut writer,
338                &arrow_schema,
339                &data_file_path,
340                std::mem::take(&mut positions),
341            )
342            .await?;
343            positions.reserve(POSITION_DELETE_WRITE_CHUNK_SIZE);
344        }
345    }
346    if !positions.is_empty() {
347        write_position_delete_chunk(&mut writer, &arrow_schema, &data_file_path, positions).await?;
348    }
349
350    let data_files = writer.close().await?;
351    // `close` will yield exactly one builder here.
352    let [mut builder] = data_files.try_into().map_err(|_| {
353        anyhow!("position-delete writer produced invalid file count for {data_file_path}")
354    })?;
355
356    // `ParquetWriter` builds the file as `DataContentType::Data` with an empty partition; override
357    // those for a file-scoped V2 position-delete file and attach `referenced_data_file`.
358    builder
359        .content(DataContentType::PositionDeletes)
360        .referenced_data_file(Some(data_file_path));
361    if let Some(partition_key) = partition_key {
362        builder
363            .partition(partition_key.data().clone())
364            .partition_spec_id(partition_key.spec().spec_id());
365    }
366    builder
367        .build()
368        .context("Failed to build position-delete file metadata")
369}
370
371/// Writes one chunk of `positions` as a `(file_path, pos)` batch into `writer`. Every row shares
372/// `data_file_path` because the delete file is file-scoped.
373async fn write_position_delete_chunk(
374    writer: &mut ParquetWriter,
375    arrow_schema: &ArrowSchemaRef,
376    data_file_path: &str,
377    positions: Vec<i64>,
378) -> Result<()> {
379    let path_column: ArrayRef = Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
380        data_file_path,
381        positions.len(),
382    )));
383    let pos_column: ArrayRef = Arc::new(Int64Array::from(positions));
384    let batch = RecordBatch::try_new(arrow_schema.clone(), vec![path_column, pos_column])
385        .map_err(|e| anyhow!(e))?;
386    writer.write(&batch).await?;
387    Ok(())
388}