1use anyhow::{Context, anyhow};
16use hashbrown::{HashMap, HashSet};
17use iceberg::delete_vector::DeleteVector;
18use iceberg::io::FileIO;
19use iceberg::spec::{DataContentType, ManifestContentType, SnapshotRef};
20use iceberg::table::Table;
21use iceberg::writer::file_writer::location_generator::DefaultLocationGenerator;
22use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask};
23use parquet::schema::types::SchemaDescriptor;
24use risingwave_common::array::DataChunk;
25use risingwave_common::array::arrow::IcebergArrowConvert;
26use risingwave_common::id::SinkId;
27use risingwave_common::row::RowExt;
28use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
29use risingwave_connector::sink::SinkError;
30use risingwave_connector::sink::iceberg::{
31 IcebergConfig, IcebergPositionDeleteCommitResult, PkIndexDataFileLocator,
32 PositionDeleteFileNameGenerators, read_position_deletes_from_file,
33 serialize_data_files_default_spec, write_position_delete_file,
34};
35use risingwave_connector::source::iceberg::ParquetFileReader;
36use risingwave_pb::connector_service::SinkMetadata;
37use risingwave_pb::id::IcebergCompactionTaskId;
38use risingwave_pb::stream_plan::iceberg_pk_index_compaction_context::{Phase, ResolverTaskInput};
39use risingwave_pb::stream_service::PbIcebergPkIndexSinkRole;
40use risingwave_rpc_client::MetaClient;
41use tokio::sync::mpsc::UnboundedReceiver;
42use uuid::Uuid;
43
44use super::load_table_at_least;
45use crate::executor::prelude::*;
46use crate::task::LocalBarrierManager;
47
48pub struct CompactionResolverExecutor {
67 ctx: ActorContextRef,
68 sink_id: SinkId,
69 iceberg_config: IcebergConfig,
70 pk_indices: Vec<usize>,
71 pk_data_types: Vec<DataType>,
72 chunk_size: usize,
73 local_barrier_manager: LocalBarrierManager,
74 barrier_receiver: UnboundedReceiver<Barrier>,
75 meta_client: MetaClient,
76}
77
78fn resolver_task_from_initial_barrier(
79 sink_id: SinkId,
80 barrier: &Barrier,
81) -> StreamExecutorResult<(IcebergCompactionTaskId, ResolverTaskInput)> {
82 match barrier.iceberg_pk_index_compaction() {
83 Some(context)
84 if barrier.is_checkpoint()
85 && context.sink_id == sink_id
86 && context.phase == Phase::Begin as i32 =>
87 {
88 let task_input = context.resolver_task_input.clone().ok_or_else(|| {
89 StreamExecutorError::from(anyhow!(
90 "compaction resolver sink {} task {} missing resolver task input",
91 sink_id,
92 context.task_id
93 ))
94 })?;
95 Ok((context.task_id, task_input))
96 }
97 _ => Err(StreamExecutorError::from(anyhow!(
98 "compaction resolver sink {} expected initial begin barrier, got {:?}",
99 sink_id,
100 barrier
101 ))),
102 }
103}
104
105fn validate_resolver_end_barrier(
106 sink_id: SinkId,
107 barrier: &Barrier,
108 begin: &Barrier,
109 task_id: IcebergCompactionTaskId,
110) -> StreamExecutorResult<()> {
111 match barrier.iceberg_pk_index_compaction() {
112 Some(context)
113 if barrier.is_checkpoint()
114 && barrier.epoch.prev == begin.epoch.curr
115 && context.sink_id == sink_id
116 && context.task_id == task_id
117 && context.phase == Phase::End as i32
118 && context.resolver_task_input.is_none() =>
119 {
120 Ok(())
121 }
122 _ => Err(StreamExecutorError::from(anyhow!(
123 "compaction resolver sink {} task {} expected end barrier, got {:?}",
124 sink_id,
125 task_id,
126 barrier
127 ))),
128 }
129}
130
131impl CompactionResolverExecutor {
132 #[expect(clippy::too_many_arguments)]
133 pub fn new(
134 ctx: ActorContextRef,
135 sink_id: SinkId,
136 iceberg_config: IcebergConfig,
137 pk_indices: Vec<usize>,
138 pk_data_types: Vec<DataType>,
139 chunk_size: usize,
140 local_barrier_manager: LocalBarrierManager,
141 barrier_receiver: UnboundedReceiver<Barrier>,
142 meta_client: MetaClient,
143 ) -> Self {
144 Self {
145 ctx,
146 sink_id,
147 iceberg_config,
148 pk_indices,
149 pk_data_types,
150 chunk_size,
151 local_barrier_manager,
152 barrier_receiver,
153 meta_client,
154 }
155 }
156
157 fn output_data_types(&self) -> Vec<DataType> {
159 let mut types = self.pk_data_types.clone();
160 types.push(if self.iceberg_config.pk_index_stores_file_uuid() {
161 DataType::Bytea
162 } else {
163 DataType::Varchar
164 });
165 types.push(DataType::Int64);
166 types
167 }
168
169 #[try_stream(ok = DataChunk, error = SinkError)]
170 async fn resolve<'a>(
171 &'a self,
172 task_input: &'a ResolverTaskInput,
173 expected_snapshot: Option<i64>,
174 conflict_delete_metadata: &'a mut Option<SinkMetadata>,
175 ) {
176 let table = load_table_at_least(&self.iceberg_config, expected_snapshot).await?;
177 let file_io = table.file_io();
178 let snapshot_r = table
179 .metadata()
180 .snapshot_by_id(task_input.read_snapshot_id)
181 .with_context(|| {
182 format!(
183 "compaction read snapshot {} not present in table metadata",
184 task_input.read_snapshot_id
185 )
186 })?;
187 let snapshot_n = table
188 .metadata()
189 .current_snapshot()
190 .context("table has no current snapshot to resolve compaction against")?;
191
192 let input_paths: HashSet<&str> = task_input
193 .input_data_file_paths
194 .iter()
195 .map(String::as_str)
196 .collect();
197
198 let dv_r = collect_input_dvs(&table, snapshot_r, &input_paths).await?;
200 let dv_n = collect_input_dvs(&table, snapshot_n, &input_paths).await?;
201
202 let mut delete_diff_pk_set: HashSet<OwnedRow> = HashSet::new();
206 let stream = futures::stream::iter(task_input.input_data_file_paths.iter().map(
207 |input_path| async {
208 let Some(n_positions) = dv_n.get(input_path) else {
209 return Ok(Vec::new());
210 };
211 let r_positions = dv_r.get(input_path);
212 let mut diff = n_positions.clone();
213 if let Some(r_positions) = r_positions {
214 diff -= r_positions;
215 }
216 if diff.is_empty() {
217 return Ok(Vec::new());
218 }
219 scan_input_pks_at_positions(file_io, input_path, &self.pk_indices, &diff).await
220 },
221 ))
222 .buffer_unordered(8);
223 #[for_await]
224 for pks in stream {
225 delete_diff_pk_set.extend(pks?);
226 }
227
228 let data_file_locator = if self.iceberg_config.pk_index_stores_file_uuid() {
230 Some(PkIndexDataFileLocator::new(table.metadata())?)
231 } else {
232 None
233 };
234 let mut conflicts: HashMap<String, DeleteVector> = HashMap::new();
235 #[for_await]
236 for chunk in self.scan_output_file(
237 file_io,
238 data_file_locator.as_ref(),
239 &task_input.output_data_file_paths,
240 &delete_diff_pk_set,
241 &mut conflicts,
242 ) {
243 yield chunk?;
244 }
245
246 *conflict_delete_metadata =
247 write_conflict_delete_files(&self.iceberg_config, &table, conflicts).await?;
248 }
249
250 #[try_stream(ok = DataChunk, error = SinkError)]
251 async fn scan_output_file<'a>(
252 &'a self,
253 file_io: &'a FileIO,
254 data_file_locator: Option<&'a PkIndexDataFileLocator>,
255 output_data_file_paths: &'a [String],
256 delete_diff_pk_set: &'a HashSet<OwnedRow>,
257 conflicts: &'a mut HashMap<String, DeleteVector>,
258 ) {
259 let mut buffer = DataChunkBuilder::new(self.output_data_types(), self.chunk_size);
260 for file in output_data_file_paths {
261 let file_uuid = data_file_locator
262 .map(|locator| locator.file_uuid(file))
263 .transpose()?;
264 let data_file = match &file_uuid {
265 None => ScalarRefImpl::Utf8(file),
266 Some(uuid) => ScalarRefImpl::Bytea(uuid.as_bytes()),
267 };
268 #[for_await]
269 for chunk in scan_output_file_inner(
270 file_io,
271 file,
272 data_file,
273 &self.pk_indices,
274 delete_diff_pk_set,
275 &mut buffer,
276 conflicts,
277 ) {
278 yield chunk?;
279 }
280 }
281 if let Some(chunk) = buffer.consume_all() {
282 yield chunk;
283 }
284 }
285
286 #[try_stream(ok = Message, error = StreamExecutorError)]
287 async fn execute_inner(mut self) {
288 let actor_id = self.ctx.id;
289
290 let first_barrier = self.barrier_receiver.recv().await.ok_or_else(|| {
292 StreamExecutorError::channel_closed("compaction resolver barrier receiver")
293 })?;
294 let (task_id, task_input) =
295 resolver_task_from_initial_barrier(self.sink_id, &first_barrier)?;
296 let begin_barrier = first_barrier.clone();
297 yield Message::Barrier(first_barrier);
298
299 let expected_snapshot = self
300 .meta_client
301 .wait_iceberg_pk_index_sink_epoch(self.sink_id, begin_barrier.epoch.prev)
302 .await?;
303
304 let mut conflict_delete_metadata = None;
305 #[for_await]
306 for chunk in self.resolve(
307 &task_input,
308 expected_snapshot,
309 &mut conflict_delete_metadata,
310 ) {
311 let chunk = chunk.map_err(|e| (e, self.sink_id))?;
312 yield Message::Chunk(chunk.into());
313 }
314
315 let Some(barrier) = self.barrier_receiver.recv().await else {
316 return Err(StreamExecutorError::channel_closed(
317 "compaction resolver barrier receiver",
318 ));
319 };
320 validate_resolver_end_barrier(self.sink_id, &barrier, &begin_barrier, task_id)?;
321 if let Some(metadata) = conflict_delete_metadata
322 && metadata.metadata.is_some()
323 {
324 self.local_barrier_manager
325 .report_iceberg_pk_index_sink_metadata(
326 barrier.epoch,
327 self.sink_id,
328 actor_id,
329 PbIcebergPkIndexSinkRole::CompactionResolver,
330 Some(metadata),
331 );
332 }
333 yield Message::Barrier(barrier);
334 }
335}
336
337impl Execute for CompactionResolverExecutor {
338 fn execute(self: Box<Self>) -> BoxedMessageStream {
339 self.execute_inner().boxed()
340 }
341}
342
343async fn collect_input_dvs(
346 table: &Table,
347 snapshot: &SnapshotRef,
348 input_paths: &HashSet<&str>,
349) -> Result<HashMap<String, DeleteVector>, SinkError> {
350 let file_io = table.file_io();
351 let mut map: HashMap<String, DeleteVector> = HashMap::new();
352
353 let manifest_list = table
354 .object_cache()
355 .get_manifest_list(snapshot, &table.metadata_ref())
356 .await?;
357
358 for manifest_file in manifest_list.entries() {
359 if manifest_file.content != ManifestContentType::Deletes {
361 continue;
362 }
363 let manifest = manifest_file.load_manifest(file_io).await?;
364 for entry in manifest.entries() {
365 if !entry.is_alive() {
366 continue;
367 }
368 let data_file = entry.data_file();
369 if data_file.content_type() == DataContentType::PositionDeletes
370 && let Some(referenced) = data_file.referenced_data_file()
371 && input_paths.contains(referenced.as_str())
372 {
373 let positions = read_position_deletes_from_file(file_io, data_file)
374 .await
375 .map_err(SinkError::Iceberg)?;
376 if map.insert(referenced, positions).is_some() {
377 return Err(SinkError::Iceberg(anyhow!(
378 "input data file {} has multiple live position-delete files in snapshot {}",
379 data_file.referenced_data_file().unwrap(),
380 snapshot.snapshot_id()
381 )));
382 }
383 }
384 }
385 }
386 Ok(map)
387}
388
389fn pk_projection(
392 parquet_schema: &SchemaDescriptor,
393 pk_indices: &[usize],
394) -> Result<(ProjectionMask, Vec<usize>), SinkError> {
395 let root_count = parquet_schema.root_schema().get_fields().len();
396 let mut physical_indices = Vec::with_capacity(pk_indices.len());
397 let mut seen = HashSet::with_capacity(pk_indices.len());
398
399 for &pk_index in pk_indices {
400 if pk_index >= root_count {
401 return Err(SinkError::Iceberg(anyhow!(
402 "pk index {pk_index} is out of range for parquet schema with {root_count} root columns"
403 )));
404 }
405 if !seen.insert(pk_index) {
406 return Err(SinkError::Iceberg(anyhow!("duplicate pk index {pk_index}")));
407 }
408 physical_indices.push(pk_index);
409 }
410
411 physical_indices.sort_unstable();
412 let pk_order = pk_indices
413 .iter()
414 .map(|pk_index| {
415 physical_indices
416 .binary_search(pk_index)
417 .expect("validated pk index must be projected")
418 })
419 .collect();
420 let projection = ProjectionMask::roots(parquet_schema, physical_indices);
421
422 Ok((projection, pk_order))
423}
424
425async fn scan_input_pks_at_positions(
426 file_io: &FileIO,
427 path: &str,
428 pk_indices: &[usize],
429 want_positions: &DeleteVector,
430) -> Result<Vec<OwnedRow>, SinkError> {
431 let mut results = Vec::new();
432 let input_file = file_io.new_input(path)?;
433 let metadata = input_file.metadata().await?;
434 let reader = input_file.reader().await?;
435 let builder = ParquetRecordBatchStreamBuilder::new(ParquetFileReader::new(metadata, reader))
436 .await
437 .map_err(|e| anyhow!(e).context(format!("open parquet reader for {path}")))?;
438 let (projection, pk_order) = pk_projection(builder.parquet_schema(), pk_indices)?;
439 let mut stream = builder
440 .with_projection(projection)
441 .build()
442 .map_err(|e| anyhow!(e).context(format!("build parquet stream for {path}")))?;
443
444 let mut base_pos = 0;
445 let mut iter = want_positions.iter().peekable();
446 while let Some(batch) = stream.next().await {
447 let batch =
448 batch.map_err(|e| anyhow!(e).context(format!("read parquet batch of {path}")))?;
449 let chunk = IcebergArrowConvert
450 .chunk_from_record_batch(&batch)
451 .map_err(|e| anyhow!(e).context(format!("convert parquet batch of {path}")))?
452 .project(&pk_order);
453 while let Some(&iter_pos) = iter.peek() {
454 let chunk_pos = iter_pos as usize - base_pos;
455 if chunk_pos >= chunk.capacity() {
456 break;
457 }
458 let pk = chunk.row_at(chunk_pos).0.to_owned_row();
459 results.push(pk);
460 iter.next();
461 }
462 base_pos += chunk.capacity();
463 }
464
465 Ok(results)
466}
467
468#[try_stream(ok = DataChunk, error = SinkError)]
469async fn scan_output_file_inner<'a>(
470 file_io: &'a FileIO,
471 path: &'a str,
472 data_file: ScalarRefImpl<'a>,
473 pk_indices: &'a [usize],
474 delete_diff_pk_set: &'a HashSet<OwnedRow>,
475 buffer: &'a mut DataChunkBuilder,
476 conflicts: &'a mut HashMap<String, DeleteVector>,
477) {
478 let input_file = file_io.new_input(path)?;
479 let metadata = input_file.metadata().await?;
480 let reader = input_file.reader().await?;
481 let builder = ParquetRecordBatchStreamBuilder::new(ParquetFileReader::new(metadata, reader))
482 .await
483 .map_err(|e| anyhow!(e).context(format!("open parquet reader for {path}")))?;
484 let (projection, pk_order) = pk_projection(builder.parquet_schema(), pk_indices)?;
485 let stream = builder
486 .with_projection(projection)
487 .build()
488 .map_err(|e| anyhow!(e).context(format!("build parquet stream for {path}")))?;
489
490 let mut position: i64 = 0;
491
492 #[for_await]
493 for batch in stream {
494 let batch =
495 batch.map_err(|e| anyhow!(e).context(format!("read parquet batch of {path}")))?;
496 let chunk = IcebergArrowConvert
497 .chunk_from_record_batch(&batch)
498 .map_err(|e| anyhow!(e).context(format!("convert parquet batch of {path}")))?
499 .project(&pk_order);
500 for row_idx in 0..chunk.capacity() {
501 let pk = chunk.row_at(row_idx).0.into_owned_row();
502 let row_position = position + row_idx as i64;
503 if delete_diff_pk_set.contains(&pk) {
504 conflicts
505 .entry_ref(path)
506 .or_default()
507 .insert(row_position as u64);
508 } else if let Some(full_chunk) = buffer.append_one_row(
509 pk.chain([Some(data_file), Some(ScalarRefImpl::Int64(row_position))]),
510 ) {
511 yield full_chunk;
512 }
513 }
514 position += chunk.capacity() as i64;
515 }
516}
517
518async fn write_conflict_delete_files(
519 config: &IcebergConfig,
520 table: &Table,
521 conflicts: HashMap<String, DeleteVector>,
522) -> Result<Option<SinkMetadata>, SinkError> {
523 if conflicts.is_empty() {
524 return Ok(None);
525 }
526
527 let location_generator = DefaultLocationGenerator::new(table.metadata())?;
528 let uuid_suffix = Uuid::now_v7();
529 let file_name_generators = PositionDeleteFileNameGenerators::new(uuid_suffix);
530 let format_version = table.metadata().format_version();
531
532 let mut delete_files = Vec::with_capacity(conflicts.len());
533 for (output_file, delete_vector) in conflicts {
534 let file = write_position_delete_file(
538 table,
539 config,
540 &location_generator,
541 &file_name_generators,
542 format_version,
543 output_file,
544 &delete_vector,
545 None,
546 )
547 .await?;
548 delete_files.push(file);
549 }
550
551 let delete_files = serialize_data_files_default_spec(table, delete_files)?;
552 let result = IcebergPositionDeleteCommitResult {
553 schema_id: table.metadata().current_schema_id(),
554 partition_spec_id: table.metadata().default_partition_spec_id(),
555 delete_files,
556 overwrite_files: vec![],
558 };
559 let sink_metadata = SinkMetadata::try_from(&result)?;
560 Ok(Some(sink_metadata))
561}
562
563#[cfg(test)]
564mod tests {
565 use std::sync::Arc;
566
567 use parquet::basic::Type as PhysicalType;
568 use parquet::schema::types::{SchemaDescriptor, Type};
569 use risingwave_common::array::DataChunkTestExt;
570 use risingwave_common::util::epoch::test_epoch;
571 use risingwave_pb::stream_plan::IcebergPkIndexCompactionContext;
572
573 use super::*;
574
575 fn test_parquet_schema() -> SchemaDescriptor {
576 SchemaDescriptor::new(Arc::new(
577 Type::group_type_builder("schema")
578 .with_fields(
579 ["column_0", "column_1", "column_2", "column_3", "column_4"]
580 .into_iter()
581 .map(|name| {
582 Arc::new(
583 Type::primitive_type_builder(name, PhysicalType::INT32)
584 .build()
585 .unwrap(),
586 )
587 })
588 .collect(),
589 )
590 .build()
591 .unwrap(),
592 ))
593 }
594
595 #[test]
596 fn test_pk_projection_restores_downstream_pk_order() {
597 let parquet_schema = test_parquet_schema();
598
599 let (_, order) = pk_projection(&parquet_schema, &[2, 0, 1]).unwrap();
600 assert_eq!(order, vec![2, 0, 1]);
601
602 let (_, order) = pk_projection(&parquet_schema, &[4, 1, 3]).unwrap();
603 assert_eq!(order, vec![2, 0, 1]);
604 }
605
606 #[test]
607 fn test_pk_projection_reorders_heterogeneous_chunk() {
608 let chunk = DataChunk::from_pretty(
609 "i T
610 7 k",
611 );
612 let projected = chunk.project(&[1, 0]);
613
614 assert_eq!(
615 projected,
616 DataChunk::from_pretty(
617 "T i
618 k 7",
619 )
620 );
621 }
622
623 fn compaction_barrier(
624 epoch: u64,
625 phase: Phase,
626 resolver_task_input: Option<ResolverTaskInput>,
627 ) -> Barrier {
628 Barrier::new_test_barrier(test_epoch(epoch)).with_iceberg_pk_index_compaction(
629 IcebergPkIndexCompactionContext {
630 sink_id: SinkId::new(7),
631 task_id: 11.into(),
632 phase: phase as i32,
633 resolver_task_input,
634 },
635 )
636 }
637
638 #[test]
639 fn test_resolver_task_is_initialized_from_b1() {
640 let task_input = ResolverTaskInput {
641 output_data_file_paths: vec!["output.parquet".into()],
642 input_data_file_paths: vec!["input.parquet".into()],
643 read_snapshot_id: 42,
644 };
645 let barrier = compaction_barrier(2, Phase::Begin, Some(task_input.clone()));
646
647 let (task_id, actual) =
648 resolver_task_from_initial_barrier(SinkId::new(7), &barrier).unwrap();
649 assert_eq!(task_id, IcebergCompactionTaskId::from(11));
650 assert_eq!(actual, task_input);
651 }
652
653 #[test]
654 fn test_resolver_requires_task_input_only_on_b1() {
655 let begin = compaction_barrier(2, Phase::Begin, Some(ResolverTaskInput::default()));
656 let end = compaction_barrier(3, Phase::End, None);
657 validate_resolver_end_barrier(SinkId::new(7), &end, &begin, 11.into()).unwrap();
658
659 let invalid_begin = compaction_barrier(2, Phase::Begin, None);
660 assert!(resolver_task_from_initial_barrier(SinkId::new(7), &invalid_begin).is_err());
661 let invalid_end = compaction_barrier(3, Phase::End, Some(ResolverTaskInput::default()));
662 assert!(
663 validate_resolver_end_barrier(SinkId::new(7), &invalid_end, &begin, 11.into()).is_err()
664 );
665 }
666}