1use anyhow::Context;
16use futures::FutureExt;
17use futures::stream::{self, StreamExt};
18use iceberg::writer::PositionDeleteInput;
19use itertools::Itertools;
20use risingwave_common::array::stream_record::Record;
21use risingwave_common::array::{DataChunk, Op};
22use risingwave_common::bail;
23use risingwave_common::id::SinkId;
24use risingwave_common::row::{Project, RowExt};
25use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
26use risingwave_common::util::epoch::EpochPair;
27use risingwave_common::util::iter_util::ZipEqFast;
28use risingwave_connector::sink::iceberg::PkIndexDataFileLocator;
29use risingwave_pb::connector_service::SinkMetadata;
30use risingwave_pb::id::IcebergCompactionTaskId;
31use risingwave_pb::stream_plan::iceberg_pk_index_compaction_context::Phase;
32use risingwave_pb::stream_plan::stream_node::StreamKind;
33use risingwave_pb::stream_service::PbIcebergPkIndexSinkRole;
34use risingwave_storage::StateStore;
35use uuid::Uuid;
36
37use crate::common::change_buffer::output_kind;
38use crate::common::compact_chunk::{InconsistencyBehavior, StreamChunkCompactor};
39use crate::executor::prelude::*;
40use crate::task::LocalBarrierManager;
41
42type PkRow<'a> = Project<'a, RowRef<'a>>;
43
44fn new_chunk_builder(chunk_size: usize) -> DataChunkBuilder {
45 DataChunkBuilder::new(vec![DataType::Varchar, DataType::Int64], chunk_size)
46}
47
48fn append_row(builder: &mut DataChunkBuilder, file_path: &str, position: i64) -> Option<DataChunk> {
49 builder.append_one_row([
50 Some(ScalarRefImpl::Utf8(file_path)),
51 Some(ScalarRefImpl::Int64(position)),
52 ])
53}
54
55#[derive(Debug)]
57pub enum WriterInputMode {
58 Normal,
59 ResolvingRight {
60 task_id: IcebergCompactionTaskId,
61 begin_epoch: EpochPair,
62 },
63 AligningReplacementInput {
64 task_id: IcebergCompactionTaskId,
65 barrier: Barrier,
66 },
67}
68
69#[async_trait::async_trait]
74pub trait IcebergWriter: Send + 'static {
75 async fn write_chunk(
77 &mut self,
78 chunk: DataChunk,
79 ) -> StreamExecutorResult<Vec<PositionDeleteInput>>;
80
81 async fn flush(&mut self) -> StreamExecutorResult<Option<SinkMetadata>>;
84}
85
86pub struct WriterExecutor<S, W>
100where
101 S: StateStore,
102 W: IcebergWriter,
103{
104 ctx: ActorContextRef,
105 input: Option<Executor>,
106 resolver_input: Option<Executor>,
107 pk_indices: Vec<usize>,
109 pk_index_state_table: StateTable<S>,
113 writer: W,
115 data_file_locator: Option<PkIndexDataFileLocator>,
118 delete_position_buffer: Option<DataChunkBuilder>,
120 mode: WriterInputMode,
121 chunk_size: usize,
122 lookup_concurrency: usize,
124 max_batch_rows: usize,
126 sink_id: SinkId,
127 local_barrier_manager: LocalBarrierManager,
128}
129
130impl<S, W> WriterExecutor<S, W>
131where
132 S: StateStore,
133 W: IcebergWriter,
134{
135 #[expect(clippy::too_many_arguments)]
136 pub fn new(
137 ctx: ActorContextRef,
138 input: Executor,
139 resolver_input: Executor,
140 pk_indices: Vec<usize>,
141 pk_index_state_table: StateTable<S>,
142 writer: W,
143 data_file_locator: Option<PkIndexDataFileLocator>,
144 chunk_size: usize,
145 lookup_concurrency: usize,
146 max_batch_rows: usize,
147 sink_id: SinkId,
148 local_barrier_manager: LocalBarrierManager,
149 ) -> Self {
150 Self {
151 ctx,
152 input: Some(input),
153 resolver_input: Some(resolver_input),
154 pk_indices,
155 pk_index_state_table,
156 writer,
157 data_file_locator,
158 delete_position_buffer: None,
159 mode: WriterInputMode::Normal,
160 chunk_size,
161 lookup_concurrency: lookup_concurrency.max(1),
162 max_batch_rows: max_batch_rows.max(1),
163 sink_id,
164 local_barrier_manager,
165 }
166 }
167
168 fn delete_index_row(
170 &mut self,
171 index_row: OwnedRow,
172 delete_position_buffer: &mut DataChunkBuilder,
173 ) -> StreamExecutorResult<Option<DataChunk>> {
174 let num_cols = index_row.len();
175 let data_file = index_row
176 .datum_at(num_cols - 2)
177 .context("data file should not be null")?;
178 let position = index_row
179 .datum_at(num_cols - 1)
180 .context("position should not be null")?
181 .into_int64();
182 let chunk = match &self.data_file_locator {
183 None => append_row(delete_position_buffer, data_file.into_utf8(), position),
184 Some(locator) => {
185 let file_path = locator
186 .file_path_from_bytes(data_file.into_bytea())
187 .map_err(|e| StreamExecutorError::sink_error(e, self.sink_id))?;
188 append_row(delete_position_buffer, &file_path, position)
189 }
190 };
191 self.pk_index_state_table.delete(index_row);
192 Ok(chunk)
193 }
194
195 #[try_stream(ok = DataChunk, error = StreamExecutorError)]
212 async fn process_chunks(&mut self, chunks: Vec<StreamChunk>) {
213 let Some(first) = chunks.first() else {
214 return Ok(());
215 };
216 let data_types = first.data_types();
221 let insert_capacity = chunks.iter().map(|c| c.capacity()).sum::<usize>() + 1;
224
225 let chunks =
229 StreamChunkCompactor::new(self.pk_indices.clone(), chunks, StreamKind::Retract)
230 .into_compacted_chunks_inline::<{ output_kind::RETRACT }>(
231 InconsistencyBehavior::Panic,
232 );
233 let pk_indices = self.pk_indices.clone();
234 let records = chunks.iter().flat_map(|c| c.records()).collect_vec();
235
236 let lookup_pks = records
238 .iter()
239 .filter_map(|record| match record {
240 Record::Insert { .. } => None,
241 Record::Delete { old_row } => Some((*old_row).project(&pk_indices)),
242 Record::Update { new_row, .. } => Some((*new_row).project(&pk_indices)),
243 })
244 .collect_vec();
245 let mut index_rows = lookup_index_rows(
246 &self.pk_index_state_table,
247 &lookup_pks,
248 self.lookup_concurrency,
249 )
250 .await?
251 .into_iter();
252
253 let mut delete_position_buffer = self
254 .delete_position_buffer
255 .take()
256 .unwrap_or_else(|| new_chunk_builder(self.chunk_size));
257 let mut insert_chunk = DataChunkBuilder::new(data_types, insert_capacity);
258 let mut insert_pks: Vec<PkRow<'_>> = Vec::new();
259
260 for record in records {
261 let new_row = match record {
262 Record::Insert { new_row } => Some(new_row),
263 Record::Delete { .. } => None,
264 Record::Update { new_row, .. } => Some(new_row),
265 };
266 if !matches!(record, Record::Insert { .. })
267 && let Some(index_row) = index_rows
268 .next()
269 .expect("one lookup result per delete or update")
270 && let Some(chunk) =
271 self.delete_index_row(index_row, &mut delete_position_buffer)?
272 {
273 yield chunk;
274 }
275 if let Some(new_row) = new_row {
276 let overflow = insert_chunk.append_one_row(new_row);
277 debug_assert!(overflow.is_none(), "insert chunk exceeds capacity");
278 insert_pks.push(new_row.project(&pk_indices));
279 }
280 }
281 debug_assert!(index_rows.next().is_none());
282
283 if !insert_pks.is_empty() {
284 let write_chunk = insert_chunk.finish();
285 let positions = self.writer.write_chunk(write_chunk).await?;
286
287 let mut last_file: Option<(&str, Uuid)> = None;
289 for (pk, pos) in insert_pks.into_iter().zip_eq_fast(positions.iter()) {
290 let mut index_row_data = Vec::with_capacity(pk_indices.len() + 2);
291 for datum in pk.iter() {
292 index_row_data.push(datum);
293 }
294 let file_uuid = match (&self.data_file_locator, last_file) {
295 (None, _) => None,
296 (Some(_), Some((path, uuid))) if path == &*pos.path => Some(uuid),
297 (Some(locator), _) => {
298 let uuid = locator
299 .file_uuid(&pos.path)
300 .map_err(|e| StreamExecutorError::sink_error(e, self.sink_id))?;
301 last_file = Some((&*pos.path, uuid));
302 Some(uuid)
303 }
304 };
305 let data_file = match &file_uuid {
306 None => ScalarRefImpl::Utf8(&pos.path),
307 Some(uuid) => ScalarRefImpl::Bytea(uuid.as_bytes()),
308 };
309 index_row_data.push(Some(data_file));
310 index_row_data.push(Some(ScalarRefImpl::Int64(pos.pos)));
311 self.pk_index_state_table.insert(index_row_data.as_slice());
312 }
313 }
314
315 self.delete_position_buffer = Some(delete_position_buffer);
316 self.pk_index_state_table.try_flush().await?;
317 }
318
319 async fn apply_resolver_chunk(&mut self, chunk: StreamChunk) -> StreamExecutorResult<()> {
320 for (op, row) in chunk.rows() {
321 if op != Op::Insert {
322 bail!(
323 "iceberg pk-index writer {} expected resolver inserts, got {op:?}",
324 self.sink_id
325 );
326 }
327 self.pk_index_state_table.insert(row);
328 }
329 self.pk_index_state_table.try_flush().await?;
330 Ok(())
331 }
332
333 #[try_stream(ok = Message, error = StreamExecutorError)]
334 async fn checkpoint_barrier(&mut self, barrier: Barrier) {
335 barrier.assume_no_update_vnode_bitmap(self.ctx.id)?;
336 let mut metadata = None;
337 if barrier.is_checkpoint() {
338 if let Some(chunk) = self
339 .delete_position_buffer
340 .take()
341 .and_then(|mut builder| builder.consume_all())
342 {
343 yield Message::Chunk(chunk.into());
344 }
345 metadata = self.writer.flush().await?;
346 }
347
348 self.pk_index_state_table
349 .commit_assert_no_update_vnode_bitmap(barrier.epoch)
350 .await?;
351 if let Some(metadata) = metadata
352 && metadata.metadata.is_some()
353 {
354 self.local_barrier_manager
355 .report_iceberg_pk_index_sink_metadata(
356 barrier.epoch,
357 self.sink_id,
358 self.ctx.id,
359 PbIcebergPkIndexSinkRole::Writer,
360 Some(metadata),
361 );
362 }
363 yield Message::Barrier(barrier);
364 }
365
366 fn validate_compaction_barrier(
367 &self,
368 barrier: &Barrier,
369 expected_task: IcebergCompactionTaskId,
370 expected_phase: Phase,
371 expected_prev: u64,
372 ) -> StreamExecutorResult<()> {
373 if !barrier.is_checkpoint() || barrier.epoch.prev != expected_prev {
374 bail!(
375 "iceberg pk-index writer {} expected checkpoint {:?} starting at {}, got {:?}",
376 self.sink_id,
377 expected_phase,
378 expected_prev,
379 barrier
380 );
381 }
382 match barrier.iceberg_pk_index_compaction() {
383 Some(context)
384 if context.sink_id == self.sink_id
385 && context.task_id == expected_task
386 && context.phase == expected_phase as i32 =>
387 {
388 Ok(())
389 }
390 _ => bail!(
391 "iceberg pk-index writer {} expected matching {:?} context for task {}, got {:?}",
392 self.sink_id,
393 expected_phase,
394 expected_task,
395 barrier
396 ),
397 }
398 }
399
400 fn validate_aligned_barriers(
401 &self,
402 left: &Barrier,
403 right: &Barrier,
404 ) -> StreamExecutorResult<()> {
405 if left.epoch != right.epoch || left.kind != right.kind || left.mutation != right.mutation {
406 bail!(
407 "iceberg pk-index writer {} received mismatched left/right barriers: left={:?}, right={:?}",
408 self.sink_id,
409 left,
410 right
411 );
412 }
413 Ok(())
414 }
415
416 fn compaction_begin(
417 &self,
418 barrier: &Barrier,
419 ) -> StreamExecutorResult<Option<IcebergCompactionTaskId>> {
420 let context = match barrier.iceberg_pk_index_compaction() {
421 Some(context) if context.sink_id == self.sink_id => context,
422 _ => return Ok(None),
423 };
424 if context.phase == Phase::End as i32 {
425 bail!(
426 "iceberg pk-index writer {} received unexpected End in Normal mode for task {}",
427 self.sink_id,
428 context.task_id
429 );
430 }
431 if context.phase != Phase::Begin as i32 {
432 bail!(
433 "iceberg pk-index writer {} expected Begin context for task {}, got {:?}",
434 self.sink_id,
435 context.task_id,
436 context.phase
437 );
438 }
439 Ok(Some(context.task_id))
440 }
441
442 #[try_stream(ok = Message, error = StreamExecutorError)]
443 async fn execute_inner(mut self) {
444 let mut input = self.input.take().unwrap().execute();
445 let mut resolver_input = self.resolver_input.take().unwrap().execute();
446
447 let barrier = expect_first_barrier(&mut input).await?;
449 let remap_first = expect_first_barrier(&mut resolver_input).await?;
450 self.validate_aligned_barriers(&barrier, &remap_first)?;
451 let first_epoch = barrier.epoch;
452 yield Message::Barrier(barrier);
453 self.pk_index_state_table.init_epoch(first_epoch).await?;
454
455 loop {
456 let mode = std::mem::replace(&mut self.mode, WriterInputMode::Normal);
457 match mode {
458 WriterInputMode::Normal => {
459 let mut completed = false;
460 #[for_await]
461 for msg in self.execute_normal(&mut input, &mut resolver_input, &mut completed)
462 {
463 yield msg?;
464 }
465 if completed {
466 break;
467 }
468 }
469 WriterInputMode::ResolvingRight {
470 task_id,
471 begin_epoch,
472 } => {
473 #[for_await]
474 for msg in
475 self.execute_resolving_right(&mut resolver_input, task_id, begin_epoch)
476 {
477 yield msg?;
478 }
479 }
480 WriterInputMode::AligningReplacementInput { task_id, barrier } => {
481 #[for_await]
482 for msg in self.execute_aligning_replacement_input(&mut input, task_id, barrier)
483 {
484 yield msg?;
485 }
486 }
487 }
488 }
489 }
490
491 #[try_stream(ok = Message, error = StreamExecutorError)]
492 async fn execute_normal<'a>(
493 &'a mut self,
494 input: &'a mut BoxedMessageStream,
495 resolver_input: &'a mut BoxedMessageStream,
496 completed: &'a mut bool,
497 ) {
498 let mut peeked: Option<Option<Message>> = None;
501 loop {
502 let next = match peeked.take() {
503 Some(next) => next,
504 None => input.next().await.transpose()?,
505 };
506 let Some(msg) = next else {
507 break;
508 };
509 match msg {
510 Message::Chunk(chunk) => {
511 let mut batch_rows = chunk.cardinality();
515 let mut chunks = vec![chunk];
516 while batch_rows < self.max_batch_rows {
517 match input.next().now_or_never() {
518 Some(Some(Ok(Message::Chunk(chunk)))) => {
519 batch_rows += chunk.cardinality();
520 chunks.push(chunk);
521 }
522 Some(next) => {
523 peeked = Some(next.transpose()?);
524 break;
525 }
526 None => break,
527 }
528 }
529 #[for_await]
530 for chunk in self.process_chunks(chunks) {
531 yield Message::Chunk(chunk?.into());
532 }
533 }
534 Message::Watermark(watermark) => {
535 yield Message::Watermark(watermark);
536 }
537 Message::Barrier(barrier) => {
538 let msg = next_msg(resolver_input).await?;
539 let remap_barrier = match msg {
540 Message::Barrier(b) => b,
541 _ => bail!(
542 "iceberg pk-index writer {} expected barrier on remap input, got {msg:?}",
543 self.sink_id
544 ),
545 };
546 self.validate_aligned_barriers(&barrier, &remap_barrier)?;
547 let begin = self.compaction_begin(&barrier)?;
548 let epoch = barrier.epoch;
549 #[for_await]
550 for msg in self.checkpoint_barrier(barrier) {
551 yield msg?;
552 }
553
554 if let Some(task_id) = begin {
555 self.mode = WriterInputMode::ResolvingRight {
556 task_id,
557 begin_epoch: epoch,
558 };
559 return Ok(());
560 }
561 }
562 }
563 }
564
565 *completed = true;
566 }
567
568 #[try_stream(ok = Message, error = StreamExecutorError)]
569 async fn execute_resolving_right<'a>(
570 &'a mut self,
571 resolver_input: &'a mut BoxedMessageStream,
572 task_id: IcebergCompactionTaskId,
573 begin_epoch: EpochPair,
574 ) {
575 #[for_await]
576 for msg in resolver_input {
577 match msg? {
578 Message::Chunk(chunk) => {
579 self.apply_resolver_chunk(chunk).await?;
580 }
581 Message::Watermark(_) => bail!(
582 "iceberg pk-index writer {} received watermark on remap input while resolving task {}",
583 self.sink_id,
584 task_id
585 ),
586 Message::Barrier(barrier) => {
587 self.validate_compaction_barrier(
588 &barrier,
589 task_id,
590 Phase::End,
591 begin_epoch.curr,
592 )?;
593 self.mode = WriterInputMode::AligningReplacementInput { task_id, barrier };
594 return Ok(());
595 }
596 }
597 }
598
599 bail!(
600 "iceberg pk-index writer {} resolver input closed before switch-to-input for task {}",
601 self.sink_id,
602 task_id
603 );
604 }
605
606 #[try_stream(ok = Message, error = StreamExecutorError)]
607 async fn execute_aligning_replacement_input<'a>(
608 &'a mut self,
609 input: &'a mut BoxedMessageStream,
610 task_id: IcebergCompactionTaskId,
611 expected: Barrier,
612 ) {
613 #[for_await]
614 for msg in input {
615 match msg? {
616 Message::Chunk(_) => bail!(
617 "iceberg pk-index writer {} received a chunk from replacement input before its initial barrier for task {}",
618 self.sink_id,
619 task_id
620 ),
621 Message::Watermark(_) => bail!(
622 "iceberg pk-index writer {} received watermark from replacement input before its initial barrier for task {}",
623 self.sink_id,
624 task_id
625 ),
626 Message::Barrier(barrier) => {
627 self.validate_aligned_barriers(&barrier, &expected)?;
628 #[for_await]
629 for msg in self.checkpoint_barrier(barrier) {
630 yield msg?;
631 }
632 self.mode = WriterInputMode::Normal;
633 return Ok(());
634 }
635 }
636 }
637
638 bail!(
639 "iceberg pk-index writer {} replacement input closed before its initial barrier for task {}",
640 self.sink_id,
641 task_id
642 );
643 }
644}
645
646impl<S, W> Execute for WriterExecutor<S, W>
647where
648 S: StateStore,
649 W: IcebergWriter,
650{
651 fn execute(self: Box<Self>) -> BoxedMessageStream {
652 self.execute_inner().boxed()
653 }
654}
655
656async fn lookup_index_rows<S: StateStore>(
659 table: &StateTable<S>,
660 pks: &[PkRow<'_>],
661 concurrency: usize,
662) -> StreamExecutorResult<Vec<Option<OwnedRow>>> {
663 let mut index_rows = vec![None; pks.len()];
664 let mut lookups = stream::iter(pks.iter().enumerate())
667 .map(|(i, pk)| async move { table.get_row(pk).await.map(|row| (i, row)) })
668 .buffer_unordered(concurrency);
669 while let Some(result) = lookups.next().await {
670 let (i, row) = result?;
671 index_rows[i] = row;
672 }
673 Ok(index_rows)
674}
675
676async fn next_msg(input: &mut BoxedMessageStream) -> StreamExecutorResult<Message> {
677 input
678 .next()
679 .await
680 .ok_or_else(|| {
681 StreamExecutorError::channel_closed(
682 "iceberg pk-index writer input channel closed unexpectedly",
683 )
684 })
685 .flatten()
686}
687
688#[cfg(test)]
689#[path = "writer_test.rs"]
690mod tests;