Skip to content

Commit 1a9401a

Browse files
committed
refactor: extract position delete helpers and simplify pk-index writer
Signed-off-by: Mingzhuo Yin <yinmingzhuo@gmail.com>
1 parent 865e37d commit 1a9401a

10 files changed

Lines changed: 787 additions & 737 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/connector/src/sink/iceberg/commit.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,14 @@ impl<'a> TryFrom<&'a IcebergCommitResult> for Vec<u8> {
159159
}
160160

161161
#[derive(Default, Clone, Serialize, Deserialize)]
162-
pub struct IcebergPositionDeleteMergerCommitResult {
162+
pub struct IcebergPositionDeleteCommitResult {
163163
pub schema_id: i32,
164164
pub partition_spec_id: i32,
165165
pub delete_files: Vec<SerializedDataFile>,
166166
pub overwrite_files: Vec<SerializedDataFile>,
167167
}
168168

169-
impl<'a> TryFrom<&'a SinkMetadata> for IcebergPositionDeleteMergerCommitResult {
169+
impl<'a> TryFrom<&'a SinkMetadata> for IcebergPositionDeleteCommitResult {
170170
type Error = SinkError;
171171

172172
fn try_from(value: &'a SinkMetadata) -> Result<Self> {
@@ -179,10 +179,10 @@ impl<'a> TryFrom<&'a SinkMetadata> for IcebergPositionDeleteMergerCommitResult {
179179
}
180180
}
181181

182-
impl<'a> TryFrom<&'a IcebergPositionDeleteMergerCommitResult> for SinkMetadata {
182+
impl<'a> TryFrom<&'a IcebergPositionDeleteCommitResult> for SinkMetadata {
183183
type Error = SinkError;
184184

185-
fn try_from(value: &'a IcebergPositionDeleteMergerCommitResult) -> Result<SinkMetadata> {
185+
fn try_from(value: &'a IcebergPositionDeleteCommitResult) -> Result<SinkMetadata> {
186186
let bytes = serde_json::to_vec(value)
187187
.context("Can't serialize iceberg dv merger commit result to metadata")?;
188188
Ok(SinkMetadata {

src/connector/src/sink/iceberg/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod engine_options;
2323
mod metadata;
2424
#[cfg(any(test, madsim))]
2525
pub mod mock_v3_catalog_registry;
26+
mod position_delete;
2627
mod prometheus;
2728
mod writer;
2829

@@ -37,6 +38,7 @@ pub use create_table::*;
3738
pub use engine_options::*;
3839
use iceberg::table::Table;
3940
pub use metadata::*;
41+
pub use position_delete::*;
4042
use risingwave_common::bail;
4143
use tokio::sync::mpsc::UnboundedSender;
4244
pub use writer::*;
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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+
}

src/meta/src/manager/iceberg_pk_index_sink/coordinator.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ use prost::Message;
4747
use risingwave_connector::sink::catalog::SinkId;
4848
use risingwave_connector::sink::iceberg::commit_retry::{self, CommitError, CommitRetryLogContext};
4949
use risingwave_connector::sink::iceberg::{
50-
IcebergCommitResult, IcebergConfig, IcebergPositionDeleteMergerCommitResult, commit_branch,
50+
IcebergCommitResult, IcebergConfig, IcebergPositionDeleteCommitResult, commit_branch,
5151
resolve_partition_type,
5252
};
5353
use risingwave_meta_model::pending_sink_state::SinkState;
@@ -591,8 +591,8 @@ fn aggregate_reports(
591591
data_files.extend(commit_result.data_files);
592592
}
593593
PbIcebergPkIndexSinkRole::PositionDeleteMerger => {
594-
let commit_result = IcebergPositionDeleteMergerCommitResult::try_from(meta)
595-
.map_err(|e| {
594+
let commit_result =
595+
IcebergPositionDeleteCommitResult::try_from(meta).map_err(|e| {
596596
anyhow!(e).context("decode pk-index sink position-delete merger metadata")
597597
})?;
598598
align_report_id(

src/stream/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ memcomparable = "0.2"
4444
moka = { version = "0.12.0", features = ["sync"] }
4545
multimap = "0.10"
4646
parking_lot = { workspace = true }
47-
parquet = { workspace = true }
4847
paste = "1"
4948
pin-project = "1"
5049
prometheus = { version = "0.14", features = ["process"] }

0 commit comments

Comments
 (0)