Skip to main content

risingwave_stream/executor/iceberg_with_pk_index/
writer.rs

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
15use anyhow::Context;
16use iceberg::writer::PositionDeleteInput;
17use risingwave_common::array::DataChunk;
18use risingwave_common::array::stream_record::Record;
19use risingwave_common::id::SinkId;
20use risingwave_common::row::{Project, RowExt};
21use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
22use risingwave_common::util::iter_util::ZipEqFast;
23use risingwave_pb::connector_service::SinkMetadata;
24use risingwave_pb::stream_service::PbIcebergPkIndexSinkRole;
25use risingwave_storage::StateStore;
26
27use crate::common::change_buffer::output_kind;
28use crate::common::compact_chunk::{InconsistencyBehavior, compact_chunk_inline};
29use crate::executor::prelude::*;
30use crate::task::LocalBarrierManager;
31
32type PkRow<'a> = Project<'a, RowRef<'a>>;
33
34fn new_chunk_builder(chunk_size: usize) -> DataChunkBuilder {
35    DataChunkBuilder::new(vec![DataType::Varchar, DataType::Int64], chunk_size)
36}
37
38fn append_row(builder: &mut DataChunkBuilder, file_path: &str, position: i64) -> Option<DataChunk> {
39    builder.append_one_row([
40        Some(ScalarRefImpl::Utf8(file_path)),
41        Some(ScalarRefImpl::Int64(position)),
42    ])
43}
44
45/// Trait abstracting the Iceberg data file writing for testability.
46///
47/// Implementations are responsible for writing rows to Iceberg data files
48/// and tracking row positions. Commit is handled by the executor, not the writer.
49#[async_trait::async_trait]
50pub trait IcebergWriter: Send + 'static {
51    /// Write a batch of insert rows. Returns the position of each row in the chunk (in order).
52    async fn write_chunk(
53        &mut self,
54        chunk: DataChunk,
55    ) -> StreamExecutorResult<Vec<PositionDeleteInput>>;
56
57    /// Flush current data files on barrier. Returns serialized commit metadata,
58    /// or `None` if no data was written since the last flush.
59    async fn flush(&mut self) -> StreamExecutorResult<Option<SinkMetadata>>;
60}
61
62/// Writer Executor for iceberg pk-index sink with PK index
63///
64/// This stateful executor maintains a PK index that maps primary key values to
65/// their position in data files (`file_path`, `position`). It processes change logs
66/// from upstream:
67///
68/// - **Insert**: Writes the row to a data file via [`IcebergWriter`], records the
69///   position in the PK index state table.
70/// - **Delete**: Looks up the PK index to find the data file position, emits a
71///   delete position message downstream to the position-delete merger, removes from index.
72/// - **Update**: Treated as Delete + Insert. The planner guarantees the old and
73///   new rows share the same PK, so the executor can reuse the projected PK from
74///   the old row when updating the PK index.
75pub struct WriterExecutor<S, W>
76where
77    S: StateStore,
78    W: IcebergWriter,
79{
80    ctx: ActorContextRef,
81    input: Option<Executor>,
82    /// Column indices of the primary key in the input schema.
83    pk_indices: Vec<usize>,
84    /// State table storing the PK index: `pk_columns` -> (`file_path`, `position`).
85    /// Schema: [`pk_col_0`, ..., `pk_col_n`, `file_path`: Varchar, `position`: Int64]
86    pk_index_state_table: StateTable<S>,
87    /// The Iceberg data file writer.
88    writer: W,
89    /// Buffer for accumulating delete position messages before the next barrier flush.
90    delete_position_buffer: Option<DataChunkBuilder>,
91    chunk_size: usize,
92    sink_id: SinkId,
93    local_barrier_manager: LocalBarrierManager,
94}
95
96impl<S, W> WriterExecutor<S, W>
97where
98    S: StateStore,
99    W: IcebergWriter,
100{
101    #[allow(clippy::too_many_arguments)]
102    pub fn new(
103        ctx: ActorContextRef,
104        input: Executor,
105        pk_indices: Vec<usize>,
106        pk_index_state_table: StateTable<S>,
107        writer: W,
108        chunk_size: usize,
109        sink_id: SinkId,
110        local_barrier_manager: LocalBarrierManager,
111    ) -> Self {
112        Self {
113            ctx,
114            input: Some(input),
115            pk_indices,
116            pk_index_state_table,
117            writer,
118            delete_position_buffer: None,
119            chunk_size,
120            sink_id,
121            local_barrier_manager,
122        }
123    }
124
125    async fn delete_existing_row(
126        &mut self,
127        pk_row: PkRow<'_>,
128        delete_position_buffer: &mut DataChunkBuilder,
129    ) -> StreamExecutorResult<Option<DataChunk>> {
130        let Some(index_row) = self.pk_index_state_table.get_row(pk_row).await? else {
131            return Ok(None);
132        };
133
134        let num_cols = index_row.len();
135        let file_path = index_row
136            .datum_at(num_cols - 2)
137            .context("file_path should not be null")?
138            .into_utf8();
139        let position = index_row
140            .datum_at(num_cols - 1)
141            .context("position should not be null")?
142            .into_int64();
143        let chunk = append_row(delete_position_buffer, file_path, position);
144        self.pk_index_state_table.delete(index_row);
145        Ok(chunk)
146    }
147
148    // Process one stream chunk:
149    //
150    // 1. Compact the chunk by `pk_indices` so each PK appears at most once and any intra-chunk
151    //    `+/-` cancellations are absorbed up front. After this step every record is either a
152    //    standalone `Insert`, `Delete`, or `Update {old, new}` whose old and new rows share the
153    //    same PK.
154    // 2. For each record: `Insert` is buffered into a single batched write; `Delete` looks up
155    //    `pk_index_state_table` to emit a position delete and clears the entry; `Update` is
156    //    handled as a position delete for the old row plus a buffered insert for the new row.
157    // 3. After the scan, write all buffered inserts in one `write_chunk` call and persist the
158    //    returned Iceberg positions back to `pk_index_state_table`.
159    //
160    // `pk_index_state_table` and `delete_position_buffer` live until the next barrier, so a later
161    // chunk in the same checkpoint observes earlier writes/deletes via the state table.
162    #[try_stream(ok = DataChunk, error = StreamExecutorError)]
163    async fn process_chunk(&mut self, chunk: StreamChunk) {
164        let chunk = compact_chunk_inline::<{ output_kind::RETRACT }>(
165            chunk,
166            &self.pk_indices,
167            InconsistencyBehavior::Panic,
168        );
169
170        let mut delete_position_buffer = self
171            .delete_position_buffer
172            .take()
173            .unwrap_or_else(|| new_chunk_builder(self.chunk_size));
174        let pk_indices = self.pk_indices.clone();
175
176        // Invariant: every input column is visible and written to Iceberg verbatim. The planner
177        // (`promote_iceberg_pk_index_stream_key` in `stream_sink.rs`) enforces this by promoting
178        // hidden stream-key columns to visible and by not adding the extra partition column for
179        // pk-index sinks, so the writer has no hidden-column projection and writes the whole row.
180        // `chunk.capacity() + 1` is an upper bound on appended rows: each surviving record
181        // contributes at most one row (Insert / Update::new), and `records()` yields at most
182        // `capacity` records.
183        let mut insert_chunk =
184            DataChunkBuilder::new(chunk.data_chunk().data_types(), chunk.capacity() + 1);
185        let mut insert_pks: Vec<PkRow<'_>> = Vec::new();
186
187        for record in chunk.records() {
188            match record {
189                Record::Insert { new_row } => {
190                    let overflow = insert_chunk.append_one_row(new_row);
191                    debug_assert!(overflow.is_none(), "insert chunk exceeds capacity");
192                    insert_pks.push(new_row.project(&pk_indices));
193                }
194                Record::Delete { old_row } => {
195                    let pk_row = old_row.project(&pk_indices);
196                    if let Some(chunk) = self
197                        .delete_existing_row(pk_row, &mut delete_position_buffer)
198                        .await?
199                    {
200                        yield chunk;
201                    }
202                }
203                Record::Update { new_row, .. } => {
204                    // The compactor groups by `pk_indices`, so old and new share the same PK.
205                    let pk_row = new_row.project(&pk_indices);
206                    if let Some(chunk) = self
207                        .delete_existing_row(pk_row, &mut delete_position_buffer)
208                        .await?
209                    {
210                        yield chunk;
211                    }
212                    let overflow = insert_chunk.append_one_row(new_row);
213                    debug_assert!(overflow.is_none(), "insert chunk exceeds capacity");
214                    insert_pks.push(pk_row);
215                }
216            }
217        }
218
219        if !insert_pks.is_empty() {
220            let write_chunk = insert_chunk.finish();
221            let positions = self.writer.write_chunk(write_chunk).await?;
222
223            for (pk, pos) in insert_pks.into_iter().zip_eq_fast(positions) {
224                let mut index_row_data = Vec::with_capacity(pk_indices.len() + 2);
225                for datum in pk.iter() {
226                    index_row_data.push(datum);
227                }
228                index_row_data.push(Some(ScalarRefImpl::Utf8(&pos.path)));
229                index_row_data.push(Some(ScalarRefImpl::Int64(pos.pos)));
230                self.pk_index_state_table.insert(index_row_data.as_slice());
231            }
232        }
233
234        self.delete_position_buffer = Some(delete_position_buffer);
235        self.pk_index_state_table.try_flush().await?;
236    }
237
238    #[try_stream(ok = Message, error = StreamExecutorError)]
239    async fn execute_inner(mut self) {
240        let mut input = self.input.take().unwrap().execute();
241
242        // Consume the first barrier.
243        let barrier = expect_first_barrier(&mut input).await?;
244        let first_epoch = barrier.epoch;
245
246        yield Message::Barrier(barrier);
247        self.pk_index_state_table.init_epoch(first_epoch).await?;
248
249        #[for_await]
250        for msg in input {
251            match msg? {
252                Message::Chunk(chunk) =>
253                {
254                    #[for_await]
255                    for data_chunk in self.process_chunk(chunk) {
256                        yield Message::Chunk(data_chunk?.into());
257                    }
258                }
259                Message::Barrier(barrier) => {
260                    let mut metadata = None;
261                    if barrier.is_checkpoint() {
262                        if let Some(chunk) = self
263                            .delete_position_buffer
264                            .take()
265                            .and_then(|mut b| b.consume_all())
266                        {
267                            yield Message::Chunk(chunk.into());
268                        }
269                        metadata = self.writer.flush().await?;
270                    }
271
272                    let epoch = barrier.epoch;
273                    let update_vnode_bitmap = barrier.as_update_vnode_bitmap(self.ctx.id);
274                    let post_commit = self.pk_index_state_table.commit(epoch).await?;
275
276                    if let Some(metadata) = metadata
277                        && metadata.metadata.is_some()
278                    {
279                        self.local_barrier_manager
280                            .report_iceberg_pk_index_sink_metadata(
281                                epoch,
282                                self.sink_id,
283                                self.ctx.id,
284                                PbIcebergPkIndexSinkRole::Writer,
285                                Some(metadata),
286                            );
287                    }
288
289                    yield Message::Barrier(barrier);
290
291                    post_commit.post_yield_barrier(update_vnode_bitmap).await?;
292                }
293                Message::Watermark(w) => {
294                    yield Message::Watermark(w);
295                }
296            }
297        }
298    }
299}
300
301impl<S, W> Execute for WriterExecutor<S, W>
302where
303    S: StateStore,
304    W: IcebergWriter,
305{
306    fn execute(self: Box<Self>) -> BoxedMessageStream {
307        self.execute_inner().boxed()
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use std::sync::{Arc, Mutex};
314
315    use iceberg::writer::PositionDeleteInput;
316    use risingwave_common::array::Op;
317    use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema, TableId};
318    use risingwave_common::id::SinkId;
319    use risingwave_common::test_prelude::StreamChunkTestExt;
320    use risingwave_common::types::DataType;
321    use risingwave_common::util::epoch::test_epoch;
322    use risingwave_common::util::sort_util::OrderType;
323    use risingwave_storage::memory::MemoryStateStore;
324
325    use super::*;
326    use crate::common::table::test_utils::gen_pbtable;
327    use crate::executor::test_utils::{MessageSender, MockSource, StreamExecutorTestExt};
328    use crate::task::LocalBarrierManager;
329
330    const CHUNK_SIZE: usize = 1024;
331    const TEST_FILE_PATH: &str = "file1.parquet";
332
333    struct IcebergWriterMock {
334        file_path: String,
335        next_offset: i64,
336        written_chunks: Arc<Mutex<Vec<StreamChunk>>>,
337    }
338
339    impl IcebergWriterMock {
340        fn new(file_path: &str) -> Self {
341            Self {
342                file_path: file_path.to_owned(),
343                next_offset: 0,
344                written_chunks: Arc::new(Mutex::new(Vec::new())),
345            }
346        }
347
348        fn written_chunks(&self) -> Arc<Mutex<Vec<StreamChunk>>> {
349            self.written_chunks.clone()
350        }
351    }
352
353    #[async_trait::async_trait]
354    impl IcebergWriter for IcebergWriterMock {
355        async fn write_chunk(
356            &mut self,
357            chunk: DataChunk,
358        ) -> StreamExecutorResult<Vec<PositionDeleteInput>> {
359            let row_count = chunk.cardinality();
360            let mut positions = Vec::with_capacity(row_count);
361            for _ in 0..row_count {
362                positions.push(PositionDeleteInput::new(
363                    Arc::<str>::from(self.file_path.as_str()),
364                    self.next_offset,
365                ));
366                self.next_offset += 1;
367            }
368            self.written_chunks.lock().unwrap().push(chunk.into());
369            Ok(positions)
370        }
371
372        async fn flush(&mut self) -> StreamExecutorResult<Option<SinkMetadata>> {
373            Ok(None)
374        }
375    }
376
377    async fn create_pk_index_state_table(
378        store: MemoryStateStore,
379        table_id: TableId,
380    ) -> StateTable<MemoryStateStore> {
381        let column_descs = vec![
382            ColumnDesc::unnamed(ColumnId::new(0), DataType::Int64),
383            ColumnDesc::unnamed(ColumnId::new(1), DataType::Varchar),
384            ColumnDesc::unnamed(ColumnId::new(2), DataType::Int64),
385        ];
386        let order_types = vec![OrderType::ascending()];
387        let pk_indices = vec![0];
388
389        StateTable::from_table_catalog(
390            &gen_pbtable(table_id, column_descs, order_types, pk_indices, 0),
391            store,
392            None,
393        )
394        .await
395    }
396
397    fn input_schema() -> Schema {
398        Schema::new(vec![
399            Field::unnamed(DataType::Int64),
400            Field::unnamed(DataType::Int64),
401        ])
402    }
403
404    fn decode_chunk(chunk: StreamChunk) -> Vec<(String, i64)> {
405        chunk
406            .rows()
407            .map(|(op, row)| {
408                assert_eq!(op, Op::Insert);
409                let file_path = row.datum_at(0).unwrap().into_utf8().to_owned();
410                let position = row.datum_at(1).unwrap().into_int64();
411                (file_path, position)
412            })
413            .collect()
414    }
415
416    fn test_file_position(position: i64) -> (String, i64) {
417        (TEST_FILE_PATH.to_owned(), position)
418    }
419
420    struct WriterTestHarness {
421        tx: MessageSender,
422        executor: BoxedMessageStream,
423        written_chunks: Arc<Mutex<Vec<StreamChunk>>>,
424    }
425
426    impl WriterTestHarness {
427        async fn new() -> Self {
428            Self::with_schema(input_schema()).await
429        }
430
431        /// Build a harness with a custom input schema. The PK is always the first column (Int64)
432        /// so the shared `create_pk_index_state_table` schema applies.
433        async fn with_schema(input_schema: Schema) -> Self {
434            let store = MemoryStateStore::new();
435            let state_table = create_pk_index_state_table(store, TableId::new(1)).await;
436            let writer = IcebergWriterMock::new(TEST_FILE_PATH);
437            let written_chunks = writer.written_chunks();
438
439            let (tx, source) = MockSource::channel();
440            let source = source.into_executor(input_schema, vec![0]);
441            let lbm = LocalBarrierManager::for_test();
442            let executor = WriterExecutor::new(
443                ActorContext::for_test(123),
444                source,
445                vec![0],
446                state_table,
447                writer,
448                CHUNK_SIZE,
449                SinkId::new(0),
450                lbm,
451            )
452            .boxed()
453            .execute();
454
455            Self {
456                tx,
457                executor,
458                written_chunks,
459            }
460        }
461
462        async fn init(&mut self) {
463            self.tx.push_barrier(test_epoch(1), false);
464            self.executor.expect_barrier().await;
465        }
466
467        fn push_chunk(&mut self, chunk: StreamChunk) {
468            self.tx.push_chunk(chunk);
469        }
470
471        fn push_pretty_chunk(&mut self, pretty: &str) {
472            self.push_chunk(StreamChunk::from_pretty(pretty));
473        }
474
475        fn push_barrier(&mut self, epoch: u64) {
476            self.tx.push_barrier(test_epoch(epoch), false);
477        }
478
479        async fn expect_barrier(&mut self) {
480            self.executor.expect_barrier().await;
481        }
482
483        async fn expect_position_chunk(&mut self, expected: Vec<(String, i64)>) {
484            assert_eq!(decode_chunk(self.executor.expect_chunk().await), expected);
485        }
486
487        fn written_chunks(&self) -> Vec<StreamChunk> {
488            self.written_chunks.lock().unwrap().clone()
489        }
490    }
491
492    #[tokio::test]
493    async fn test_writer_executor_insert_only() {
494        let mut harness = WriterTestHarness::new().await;
495        harness.init().await;
496
497        harness.push_pretty_chunk(
498            " I I
499            + 1 10
500            + 2 20
501            + 3 30",
502        );
503        harness.push_barrier(2);
504
505        harness.expect_barrier().await;
506        assert_eq!(
507            harness.written_chunks(),
508            vec![StreamChunk::from_pretty(
509                " I I
510                + 1 10
511                + 2 20
512                + 3 30",
513            )]
514        );
515    }
516
517    #[tokio::test]
518    async fn test_writer_executor_insert_then_delete() {
519        let mut harness = WriterTestHarness::new().await;
520        harness.init().await;
521
522        harness.push_pretty_chunk(
523            " I I
524            + 1 10
525            + 2 20
526            + 3 30",
527        );
528        harness.push_barrier(2);
529        harness.expect_barrier().await;
530
531        harness.push_pretty_chunk(
532            " I I
533            - 2 20",
534        );
535        harness.push_barrier(3);
536
537        harness
538            .expect_position_chunk(vec![test_file_position(1)])
539            .await;
540        harness.expect_barrier().await;
541    }
542
543    #[tokio::test]
544    async fn test_writer_executor_update_rewrites_position() {
545        let mut harness = WriterTestHarness::new().await;
546        harness.init().await;
547
548        harness.push_pretty_chunk(
549            " I I
550            + 1 10",
551        );
552        harness.push_barrier(2);
553        harness.expect_barrier().await;
554
555        harness.push_pretty_chunk(
556            " I I
557            U- 1 10
558            U+ 1 99",
559        );
560        harness.push_barrier(3);
561
562        harness
563            .expect_position_chunk(vec![test_file_position(0)])
564            .await;
565        harness.expect_barrier().await;
566
567        harness.push_pretty_chunk(
568            " I I
569            - 1 99",
570        );
571        harness.push_barrier(4);
572
573        harness
574            .expect_position_chunk(vec![test_file_position(1)])
575            .await;
576        harness.expect_barrier().await;
577
578        assert_eq!(
579            harness.written_chunks(),
580            vec![
581                StreamChunk::from_pretty(
582                    " I I
583                    + 1 10",
584                ),
585                StreamChunk::from_pretty(
586                    " I I
587                    + 1 99",
588                ),
589            ]
590        );
591    }
592
593    #[tokio::test]
594    async fn test_writer_executor_delete_then_insert_without_existing_row_is_fresh_insert() {
595        let mut harness = WriterTestHarness::new().await;
596        harness.init().await;
597
598        harness.push_pretty_chunk(
599            " I I
600            - 1 10
601            + 1 99",
602        );
603        harness.push_barrier(2);
604
605        harness.expect_barrier().await;
606        assert_eq!(
607            harness.written_chunks(),
608            vec![StreamChunk::from_pretty(
609                " I I
610                + 1 99",
611            )]
612        );
613    }
614
615    #[tokio::test]
616    async fn test_writer_executor_delete_then_insert_rewrites_existing_row() {
617        let mut harness = WriterTestHarness::new().await;
618        harness.init().await;
619
620        harness.push_pretty_chunk(
621            " I I
622            + 1 10",
623        );
624        harness.push_barrier(2);
625        harness.expect_barrier().await;
626
627        harness.push_pretty_chunk(
628            " I I
629            - 1 10
630            + 1 99",
631        );
632        harness.push_barrier(3);
633
634        harness
635            .expect_position_chunk(vec![test_file_position(0)])
636            .await;
637        harness.expect_barrier().await;
638
639        assert_eq!(
640            harness.written_chunks(),
641            vec![
642                StreamChunk::from_pretty(
643                    " I I
644                    + 1 10",
645                ),
646                StreamChunk::from_pretty(
647                    " I I
648                    + 1 99",
649                ),
650            ]
651        );
652    }
653
654    /// Two deletes for the same PK within one chunk are inconsistent input: the PK is derived from
655    /// the upstream stream key, which guarantees uniqueness within a chunk. The writer panics on
656    /// compaction rather than silently swallowing the duplicate.
657    #[tokio::test]
658    #[should_panic(expected = "inconsistency happened")]
659    async fn test_writer_executor_duplicate_delete_in_same_chunk_panics() {
660        let mut harness = WriterTestHarness::new().await;
661        harness.init().await;
662
663        harness.push_pretty_chunk(
664            " I I
665            + 1 10",
666        );
667        harness.push_barrier(2);
668        harness.expect_barrier().await;
669
670        harness.push_pretty_chunk(
671            " I I
672            - 1 10
673            - 1 10",
674        );
675        harness.push_barrier(3);
676
677        // Processing the duplicate-delete chunk panics during compaction.
678        harness.expect_barrier().await;
679    }
680
681    #[tokio::test]
682    async fn test_writer_executor_insert_then_delete_in_different_chunks_same_checkpoint() {
683        let mut harness = WriterTestHarness::new().await;
684        harness.init().await;
685
686        harness.push_pretty_chunk(
687            " I I
688            + 1 10",
689        );
690        harness.push_pretty_chunk(
691            " I I
692            - 1 10",
693        );
694        harness.push_barrier(2);
695
696        harness
697            .expect_position_chunk(vec![test_file_position(0)])
698            .await;
699        harness.expect_barrier().await;
700        assert_eq!(
701            harness.written_chunks(),
702            vec![StreamChunk::from_pretty(
703                " I I
704                + 1 10",
705            )]
706        );
707    }
708
709    /// Two inserts for the same PK within one chunk are inconsistent input: the upstream stream key
710    /// guarantees PK uniqueness within a chunk, so the writer panics on compaction.
711    #[tokio::test]
712    #[should_panic(expected = "inconsistency happened")]
713    async fn test_writer_executor_duplicate_insert_in_same_chunk_panics() {
714        let mut harness = WriterTestHarness::new().await;
715        harness.init().await;
716
717        harness.push_pretty_chunk(
718            " I I
719            + 1 10
720            + 1 99",
721        );
722        harness.push_barrier(2);
723
724        // Processing the duplicate-insert chunk panics during compaction.
725        harness.expect_barrier().await;
726    }
727
728    #[tokio::test]
729    async fn test_writer_executor_insert_then_delete_in_same_chunk_is_cancelled() {
730        let mut harness = WriterTestHarness::new().await;
731        harness.init().await;
732
733        harness.push_pretty_chunk(
734            " I I
735            + 1 10
736            - 1 10",
737        );
738        harness.push_barrier(2);
739
740        harness.expect_barrier().await;
741        assert!(harness.written_chunks().is_empty());
742    }
743}