risingwave_connector/sink/iceberg/
position_delete.rs1use 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#[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
88pub 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
136const DELETION_VECTOR_PROPERTY_CARDINALITY: &str = "cardinality";
138const DELETION_VECTOR_PROPERTY_REFERENCED_DATA_FILE: &str = "referenced-data-file";
140
141pub 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
182pub 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 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 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
223pub 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
241pub 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
297const POSITION_DELETE_WRITE_CHUNK_SIZE: usize = 1024;
299
300pub 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 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 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 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
371async 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}