|
| 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 | +
|
| 17 | +use std::collections::HashMap; |
| 18 | +use std::sync::Arc; |
| 19 | + |
| 20 | +use anyhow::{Context, Result, anyhow, bail}; |
| 21 | +use futures::StreamExt; |
| 22 | +use iceberg::arrow::schema_to_arrow_schema; |
| 23 | +use iceberg::delete_vector::DeleteVector; |
| 24 | +use iceberg::io::FileIO; |
| 25 | +use iceberg::puffin::{CompressionCodec, PuffinReader, PuffinWriter}; |
| 26 | +use iceberg::spec::{DataContentType, DataFile, DataFileBuilder, DataFileFormat, PartitionKey}; |
| 27 | +use iceberg::table::Table; |
| 28 | +use iceberg::writer::base_writer::position_delete_file_writer::POSITION_DELETE_SCHEMA; |
| 29 | +use iceberg::writer::file_writer::location_generator::{ |
| 30 | + DefaultFileNameGenerator, DefaultLocationGenerator, FileNameGenerator, LocationGenerator, |
| 31 | +}; |
| 32 | +use iceberg::writer::file_writer::{ |
| 33 | + FileWriter, FileWriterBuilder, ParquetWriter, ParquetWriterBuilder, |
| 34 | +}; |
| 35 | +use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask}; |
| 36 | +use parquet::file::properties::WriterProperties; |
| 37 | +use risingwave_common::array::arrow::arrow_array_iceberg::{ |
| 38 | + Array, ArrayRef, Int64Array, RecordBatch, StringArray, |
| 39 | +}; |
| 40 | +use risingwave_common::array::arrow::arrow_schema_iceberg::SchemaRef as ArrowSchemaRef; |
| 41 | + |
| 42 | +use crate::sink::iceberg::{IcebergConfig, PARQUET_CREATED_BY}; |
| 43 | +use crate::source::iceberg::parquet_file_handler::ParquetFileReader; |
| 44 | + |
| 45 | +/// Puffin blob property for deletion vector cardinality. |
| 46 | +const DELETION_VECTOR_PROPERTY_CARDINALITY: &str = "cardinality"; |
| 47 | +/// Puffin blob property for referenced data file path. |
| 48 | +const DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE: &str = "referenced-data-file"; |
| 49 | + |
| 50 | +/// Reads the deletion-vector positions of a single Puffin DV `DataFile`. |
| 51 | +pub async fn read_dv_positions_from_data_file( |
| 52 | + file_io: &FileIO, |
| 53 | + data_file: &DataFile, |
| 54 | +) -> Result<DeleteVector> { |
| 55 | + let blob_offset = data_file.content_offset().with_context(|| { |
| 56 | + format!( |
| 57 | + "DV file {} missing content_offset for referenced data file {:?}", |
| 58 | + data_file.file_path(), |
| 59 | + data_file.referenced_data_file() |
| 60 | + ) |
| 61 | + })?; |
| 62 | + let blob_length = data_file.content_size_in_bytes().with_context(|| { |
| 63 | + format!( |
| 64 | + "DV file {} missing content_size_in_bytes for referenced data file {:?}", |
| 65 | + data_file.file_path(), |
| 66 | + data_file.referenced_data_file() |
| 67 | + ) |
| 68 | + })?; |
| 69 | + |
| 70 | + let input_file = file_io.new_input(data_file.file_path())?; |
| 71 | + let puffin_reader = PuffinReader::new(input_file); |
| 72 | + let file_metadata = puffin_reader.file_metadata().await?; |
| 73 | + let blob_metadata = file_metadata |
| 74 | + .blobs() |
| 75 | + .iter() |
| 76 | + .find(|blob| blob.offset() == blob_offset as u64 && blob.length() == blob_length as u64) |
| 77 | + .with_context(|| { |
| 78 | + format!( |
| 79 | + "DV blob metadata not found in {} at offset={} length={}", |
| 80 | + data_file.file_path(), |
| 81 | + blob_offset, |
| 82 | + blob_length |
| 83 | + ) |
| 84 | + })?; |
| 85 | + let blob = puffin_reader.blob(blob_metadata).await?; |
| 86 | + |
| 87 | + let delete_vector = DeleteVector::from_puffin_blob(blob)?; |
| 88 | + Ok(delete_vector) |
| 89 | +} |
| 90 | + |
| 91 | +/// Reads the positions stored in a V2 Parquet position-delete file into a [`DeleteVector`]. |
| 92 | +/// |
| 93 | +/// The file's schema is `(file_path, pos)`. Callers only invoke this after the entry's |
| 94 | +/// `referenced_data_file` already matched the target data file, and the files we write |
| 95 | +/// are file-scoped (every row shares one `file_path`), so the `file_path` column is |
| 96 | +/// redundant here: we project only the `pos` column and read every value. |
| 97 | +pub async fn read_parquet_position_deletes_from_file( |
| 98 | + file_io: &FileIO, |
| 99 | + delete_file: &DataFile, |
| 100 | +) -> Result<DeleteVector> { |
| 101 | + let input_file = file_io.new_input(delete_file.file_path())?; |
| 102 | + let metadata = input_file.metadata().await?; |
| 103 | + let reader = input_file.reader().await?; |
| 104 | + let parquet_reader = ParquetFileReader::new(metadata, reader); |
| 105 | + let builder = ParquetRecordBatchStreamBuilder::new(parquet_reader).await?; |
| 106 | + // Project only the `pos` leaf (column index 1) so the `file_path` column is never decoded. |
| 107 | + let projection = ProjectionMask::leaves(builder.parquet_schema(), [1]); |
| 108 | + let mut stream = builder.with_projection(projection).build()?; |
| 109 | + |
| 110 | + let mut delete_vector = DeleteVector::default(); |
| 111 | + while let Some(batch) = stream.next().await { |
| 112 | + let batch = batch?; |
| 113 | + // Only the projected `pos` column is present in the batch. |
| 114 | + let positions = batch.columns()[0] |
| 115 | + .as_any() |
| 116 | + .downcast_ref::<Int64Array>() |
| 117 | + .context("position-delete pos column should be an Int64Array")?; |
| 118 | + for pos in positions { |
| 119 | + let pos = pos.with_context(|| { |
| 120 | + format!( |
| 121 | + "null value in position-delete file {}", |
| 122 | + delete_file.file_path() |
| 123 | + ) |
| 124 | + })?; |
| 125 | + delete_vector.insert(pos as u64); |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + Ok(delete_vector) |
| 130 | +} |
| 131 | + |
| 132 | +/// Reads the deleted positions of a single position-delete `DataFile` regardless of on-disk format, |
| 133 | +pub async fn read_position_deletes_from_file( |
| 134 | + file_io: &FileIO, |
| 135 | + delete_file: &DataFile, |
| 136 | +) -> Result<DeleteVector> { |
| 137 | + match delete_file.file_format() { |
| 138 | + DataFileFormat::Puffin => read_dv_positions_from_data_file(file_io, delete_file).await, |
| 139 | + DataFileFormat::Parquet => { |
| 140 | + read_parquet_position_deletes_from_file(file_io, delete_file).await |
| 141 | + } |
| 142 | + other => bail!( |
| 143 | + "position-delete file {} has unsupported format {:?}; expected Puffin or Parquet", |
| 144 | + delete_file.file_path(), |
| 145 | + other |
| 146 | + ), |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +/// Writes `delete_vector` as a single Puffin deletion-vector blob referencing `data_file_path`, |
| 151 | +/// and returns its [`DataFile`] metadata (content `PositionDeletes`, format `Puffin`) with |
| 152 | +/// `referenced_data_file` set. |
| 153 | +pub async fn write_dv_puffin_file( |
| 154 | + table: &Table, |
| 155 | + location_generator: &DefaultLocationGenerator, |
| 156 | + file_name_generator: &DefaultFileNameGenerator, |
| 157 | + data_file_path: String, |
| 158 | + delete_vector: &DeleteVector, |
| 159 | + partition_key: Option<&PartitionKey>, |
| 160 | +) -> Result<DataFile> { |
| 161 | + let file_name = file_name_generator.generate_file_name(); |
| 162 | + let location = location_generator.generate_location(partition_key, &file_name); |
| 163 | + let output_file = table.file_io().new_output(&location)?; |
| 164 | + let mut writer = PuffinWriter::new(&output_file, HashMap::new(), false).await?; |
| 165 | + |
| 166 | + let cardinality = delete_vector.len(); |
| 167 | + let properties = HashMap::from([ |
| 168 | + ( |
| 169 | + DELETION_VECTOR_PROPERTY_CARDINALITY.to_owned(), |
| 170 | + cardinality.to_string(), |
| 171 | + ), |
| 172 | + ( |
| 173 | + DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE.to_owned(), |
| 174 | + data_file_path.clone(), |
| 175 | + ), |
| 176 | + ]); |
| 177 | + let blob = delete_vector.to_puffin_blob(properties)?; |
| 178 | + writer.add(blob, CompressionCodec::None).await?; |
| 179 | + |
| 180 | + let result = writer.close_with_metadata().await?; |
| 181 | + let blob_metadata = result |
| 182 | + .blobs_metadata |
| 183 | + .first() |
| 184 | + .context("blob metadata should be present")?; |
| 185 | + |
| 186 | + let mut builder = DataFileBuilder::default(); |
| 187 | + builder |
| 188 | + .content(DataContentType::PositionDeletes) |
| 189 | + .file_path(location) |
| 190 | + .file_format(DataFileFormat::Puffin) |
| 191 | + .record_count(cardinality) |
| 192 | + .file_size_in_bytes(result.file_size_in_bytes) |
| 193 | + .referenced_data_file(Some(data_file_path)) |
| 194 | + .content_offset(Some(blob_metadata.offset() as i64)) |
| 195 | + .content_size_in_bytes(Some(blob_metadata.length() as i64)); |
| 196 | + if let Some(partition_key) = partition_key { |
| 197 | + builder |
| 198 | + .partition(partition_key.data().clone()) |
| 199 | + .partition_spec_id(partition_key.spec().spec_id()); |
| 200 | + } |
| 201 | + builder |
| 202 | + .build() |
| 203 | + .context("Failed to build deletion vector file metadata") |
| 204 | +} |
| 205 | + |
| 206 | +/// How many positions to buffer before flushing one `(file_path, pos)` batch to the writer. |
| 207 | +const POSITION_DELETE_WRITE_CHUNK_SIZE: usize = 1024; |
| 208 | + |
| 209 | +/// Writes `delete_vector` as a single file-scoped Parquet position-delete file referencing |
| 210 | +/// `data_file_path`, and returns its [`DataFile`] metadata (content `PositionDeletes`, format |
| 211 | +/// `Parquet`) with `referenced_data_file` set. |
| 212 | +pub async fn write_parquet_position_delete_file( |
| 213 | + table: &Table, |
| 214 | + location_generator: &DefaultLocationGenerator, |
| 215 | + file_name_generator: &DefaultFileNameGenerator, |
| 216 | + config: &IcebergConfig, |
| 217 | + data_file_path: String, |
| 218 | + delete_vector: &DeleteVector, |
| 219 | + partition_key: Option<&PartitionKey>, |
| 220 | +) -> Result<DataFile> { |
| 221 | + let file_name = file_name_generator.generate_file_name(); |
| 222 | + let location = location_generator.generate_location(partition_key, &file_name); |
| 223 | + let output_file = table.file_io().new_output(&location)?; |
| 224 | + |
| 225 | + let parquet_writer_properties = WriterProperties::builder() |
| 226 | + .set_compression(config.get_parquet_compression()) |
| 227 | + .set_max_row_group_bytes(config.write_parquet_max_row_group_bytes()) |
| 228 | + .set_created_by(PARQUET_CREATED_BY.to_owned()) |
| 229 | + .build(); |
| 230 | + let mut writer = ParquetWriterBuilder::new( |
| 231 | + parquet_writer_properties, |
| 232 | + POSITION_DELETE_SCHEMA.clone().into(), |
| 233 | + ) |
| 234 | + .build(output_file) |
| 235 | + .await?; |
| 236 | + |
| 237 | + // The position-delete schema is `(file_path, pos)` with reserved field IDs; derive the matching |
| 238 | + // Arrow schema so the written column field IDs line up. |
| 239 | + let arrow_schema: ArrowSchemaRef = Arc::new(schema_to_arrow_schema(&POSITION_DELETE_SCHEMA)?); |
| 240 | + |
| 241 | + let mut positions: Vec<i64> = Vec::with_capacity(POSITION_DELETE_WRITE_CHUNK_SIZE); |
| 242 | + for pos in delete_vector.iter() { |
| 243 | + positions.push(pos as i64); |
| 244 | + if positions.len() == POSITION_DELETE_WRITE_CHUNK_SIZE { |
| 245 | + write_position_delete_chunk( |
| 246 | + &mut writer, |
| 247 | + &arrow_schema, |
| 248 | + &data_file_path, |
| 249 | + std::mem::take(&mut positions), |
| 250 | + ) |
| 251 | + .await?; |
| 252 | + positions.reserve(POSITION_DELETE_WRITE_CHUNK_SIZE); |
| 253 | + } |
| 254 | + } |
| 255 | + if !positions.is_empty() { |
| 256 | + write_position_delete_chunk(&mut writer, &arrow_schema, &data_file_path, positions).await?; |
| 257 | + } |
| 258 | + |
| 259 | + let data_files = writer.close().await?; |
| 260 | + // `close` will yield exactly one builder here. |
| 261 | + let [mut builder] = data_files.try_into().map_err(|_| { |
| 262 | + anyhow!("position-delete writer produced invalid file count for {data_file_path}") |
| 263 | + })?; |
| 264 | + |
| 265 | + // `ParquetWriter` builds the file as `DataContentType::Data` with an empty partition; override |
| 266 | + // those for a file-scoped V2 position-delete file and attach `referenced_data_file`. |
| 267 | + builder |
| 268 | + .content(DataContentType::PositionDeletes) |
| 269 | + .referenced_data_file(Some(data_file_path)); |
| 270 | + if let Some(partition_key) = partition_key { |
| 271 | + builder |
| 272 | + .partition(partition_key.data().clone()) |
| 273 | + .partition_spec_id(partition_key.spec().spec_id()); |
| 274 | + } |
| 275 | + builder |
| 276 | + .build() |
| 277 | + .context("Failed to build position-delete file metadata") |
| 278 | +} |
| 279 | + |
| 280 | +/// Writes one chunk of `positions` as a `(file_path, pos)` batch into `writer`. Every row shares |
| 281 | +/// `data_file_path` because the delete file is file-scoped. |
| 282 | +async fn write_position_delete_chunk( |
| 283 | + writer: &mut ParquetWriter, |
| 284 | + arrow_schema: &ArrowSchemaRef, |
| 285 | + data_file_path: &str, |
| 286 | + positions: Vec<i64>, |
| 287 | +) -> Result<()> { |
| 288 | + let path_column: ArrayRef = Arc::new(StringArray::from_iter_values(std::iter::repeat_n( |
| 289 | + data_file_path, |
| 290 | + positions.len(), |
| 291 | + ))); |
| 292 | + let pos_column: ArrayRef = Arc::new(Int64Array::from(positions)); |
| 293 | + let batch = RecordBatch::try_new(arrow_schema.clone(), vec![path_column, pos_column]) |
| 294 | + .map_err(|e| anyhow!(e))?; |
| 295 | + writer.write(&batch).await?; |
| 296 | + Ok(()) |
| 297 | +} |
0 commit comments