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::stream_record::Record;
18use risingwave_common::array::{DataChunk, Op};
19use risingwave_common::bail;
20use risingwave_common::id::SinkId;
21use risingwave_common::row::{Project, RowExt};
22use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
23use risingwave_common::util::epoch::EpochPair;
24use risingwave_common::util::iter_util::ZipEqFast;
25use risingwave_pb::connector_service::SinkMetadata;
26use risingwave_pb::id::IcebergCompactionTaskId;
27use risingwave_pb::stream_plan::iceberg_pk_index_compaction_context::Phase;
28use risingwave_pb::stream_service::PbIcebergPkIndexSinkRole;
29use risingwave_storage::StateStore;
30
31use crate::common::change_buffer::output_kind;
32use crate::common::compact_chunk::{InconsistencyBehavior, compact_chunk_inline};
33use crate::executor::prelude::*;
34use crate::task::LocalBarrierManager;
35
36type PkRow<'a> = Project<'a, RowRef<'a>>;
37
38fn new_chunk_builder(chunk_size: usize) -> DataChunkBuilder {
39    DataChunkBuilder::new(vec![DataType::Varchar, DataType::Int64], chunk_size)
40}
41
42fn append_row(builder: &mut DataChunkBuilder, file_path: &str, position: i64) -> Option<DataChunk> {
43    builder.append_one_row([
44        Some(ScalarRefImpl::Utf8(file_path)),
45        Some(ScalarRefImpl::Int64(position)),
46    ])
47}
48
49/// Input-selection state for the writer's dual-input compaction protocol.
50#[derive(Debug)]
51pub enum WriterInputMode {
52    Normal,
53    ResolvingRight {
54        task_id: IcebergCompactionTaskId,
55        begin_epoch: EpochPair,
56    },
57    AligningReplacementInput {
58        task_id: IcebergCompactionTaskId,
59        barrier: Barrier,
60    },
61}
62
63/// Trait abstracting the Iceberg data file writing for testability.
64///
65/// Implementations are responsible for writing rows to Iceberg data files
66/// and tracking row positions. Commit is handled by the executor, not the writer.
67#[async_trait::async_trait]
68pub trait IcebergWriter: Send + 'static {
69    /// Write a batch of insert rows. Returns the position of each row in the chunk (in order).
70    async fn write_chunk(
71        &mut self,
72        chunk: DataChunk,
73    ) -> StreamExecutorResult<Vec<PositionDeleteInput>>;
74
75    /// Flush current data files on barrier. Returns serialized commit metadata,
76    /// or `None` if no data was written since the last flush.
77    async fn flush(&mut self) -> StreamExecutorResult<Option<SinkMetadata>>;
78}
79
80/// Writer Executor for iceberg pk-index sink with PK index
81///
82/// This stateful executor maintains a PK index that maps primary key values to
83/// their position in data files (`file_path`, `position`). It processes change logs
84/// from upstream:
85///
86/// - **Insert**: Writes the row to a data file via [`IcebergWriter`], records the
87///   position in the PK index state table.
88/// - **Delete**: Looks up the PK index to find the data file position, emits a
89///   delete position message downstream to the position-delete merger, removes from index.
90/// - **Update**: Treated as Delete + Insert. The planner guarantees the old and
91///   new rows share the same PK, so the executor can reuse the projected PK from
92///   the old row when updating the PK index.
93pub struct WriterExecutor<S, W>
94where
95    S: StateStore,
96    W: IcebergWriter,
97{
98    ctx: ActorContextRef,
99    input: Option<Executor>,
100    resolver_input: Option<Executor>,
101    /// Column indices of the primary key in the input schema.
102    pk_indices: Vec<usize>,
103    /// State table storing the PK index: `pk_columns` -> (`file_path`, `position`).
104    /// Schema: [`pk_col_0`, ..., `pk_col_n`, `file_path`: Varchar, `position`: Int64]
105    pk_index_state_table: StateTable<S>,
106    /// The Iceberg data file writer.
107    writer: W,
108    /// Buffer for accumulating delete position messages before the next barrier flush.
109    delete_position_buffer: Option<DataChunkBuilder>,
110    mode: WriterInputMode,
111    chunk_size: usize,
112    sink_id: SinkId,
113    local_barrier_manager: LocalBarrierManager,
114}
115
116impl<S, W> WriterExecutor<S, W>
117where
118    S: StateStore,
119    W: IcebergWriter,
120{
121    #[expect(clippy::too_many_arguments)]
122    pub fn new(
123        ctx: ActorContextRef,
124        input: Executor,
125        resolver_input: Executor,
126        pk_indices: Vec<usize>,
127        pk_index_state_table: StateTable<S>,
128        writer: W,
129        chunk_size: usize,
130        sink_id: SinkId,
131        local_barrier_manager: LocalBarrierManager,
132    ) -> Self {
133        Self {
134            ctx,
135            input: Some(input),
136            resolver_input: Some(resolver_input),
137            pk_indices,
138            pk_index_state_table,
139            writer,
140            delete_position_buffer: None,
141            mode: WriterInputMode::Normal,
142            chunk_size,
143            sink_id,
144            local_barrier_manager,
145        }
146    }
147
148    async fn delete_existing_row(
149        &mut self,
150        pk_row: PkRow<'_>,
151        delete_position_buffer: &mut DataChunkBuilder,
152    ) -> StreamExecutorResult<Option<DataChunk>> {
153        let Some(index_row) = self.pk_index_state_table.get_row(pk_row).await? else {
154            return Ok(None);
155        };
156
157        let num_cols = index_row.len();
158        let file_path = index_row
159            .datum_at(num_cols - 2)
160            .context("file_path should not be null")?
161            .into_utf8();
162        let position = index_row
163            .datum_at(num_cols - 1)
164            .context("position should not be null")?
165            .into_int64();
166        let chunk = append_row(delete_position_buffer, file_path, position);
167        self.pk_index_state_table.delete(index_row);
168        Ok(chunk)
169    }
170
171    // Process one stream chunk:
172    //
173    // 1. Compact the chunk by `pk_indices` so each PK appears at most once and any intra-chunk
174    //    `+/-` cancellations are absorbed up front. After this step every record is either a
175    //    standalone `Insert`, `Delete`, or `Update {old, new}` whose old and new rows share the
176    //    same PK.
177    // 2. For each record: `Insert` is buffered into a single batched write; `Delete` looks up
178    //    `pk_index_state_table` to emit a position delete and clears the entry; `Update` is
179    //    handled as a position delete for the old row plus a buffered insert for the new row.
180    // 3. After the scan, write all buffered inserts in one `write_chunk` call and persist the
181    //    returned Iceberg positions back to `pk_index_state_table`.
182    //
183    // `pk_index_state_table` and `delete_position_buffer` live until the next barrier, so a later
184    // chunk in the same checkpoint observes earlier writes/deletes via the state table.
185    #[try_stream(ok = DataChunk, error = StreamExecutorError)]
186    async fn process_chunk(&mut self, chunk: StreamChunk) {
187        let chunk = compact_chunk_inline::<{ output_kind::RETRACT }>(
188            chunk,
189            &self.pk_indices,
190            InconsistencyBehavior::Panic,
191        );
192
193        let mut delete_position_buffer = self
194            .delete_position_buffer
195            .take()
196            .unwrap_or_else(|| new_chunk_builder(self.chunk_size));
197        let pk_indices = self.pk_indices.clone();
198
199        // Invariant: every input column is visible and written to Iceberg verbatim. The planner
200        // (`promote_iceberg_pk_index_stream_key` in `stream_sink.rs`) enforces this by promoting
201        // hidden stream-key columns to visible and by not adding the extra partition column for
202        // pk-index sinks, so the writer has no hidden-column projection and writes the whole row.
203        // `chunk.capacity() + 1` is an upper bound on appended rows: each surviving record
204        // contributes at most one row (Insert / Update::new), and `records()` yields at most
205        // `capacity` records.
206        let mut insert_chunk =
207            DataChunkBuilder::new(chunk.data_chunk().data_types(), chunk.capacity() + 1);
208        let mut insert_pks: Vec<PkRow<'_>> = Vec::new();
209
210        for record in chunk.records() {
211            match record {
212                Record::Insert { new_row } => {
213                    let overflow = insert_chunk.append_one_row(new_row);
214                    debug_assert!(overflow.is_none(), "insert chunk exceeds capacity");
215                    insert_pks.push(new_row.project(&pk_indices));
216                }
217                Record::Delete { old_row } => {
218                    let pk_row = old_row.project(&pk_indices);
219                    if let Some(chunk) = self
220                        .delete_existing_row(pk_row, &mut delete_position_buffer)
221                        .await?
222                    {
223                        yield chunk;
224                    }
225                }
226                Record::Update { new_row, .. } => {
227                    // The compactor groups by `pk_indices`, so old and new share the same PK.
228                    let pk_row = new_row.project(&pk_indices);
229                    if let Some(chunk) = self
230                        .delete_existing_row(pk_row, &mut delete_position_buffer)
231                        .await?
232                    {
233                        yield chunk;
234                    }
235                    let overflow = insert_chunk.append_one_row(new_row);
236                    debug_assert!(overflow.is_none(), "insert chunk exceeds capacity");
237                    insert_pks.push(pk_row);
238                }
239            }
240        }
241
242        if !insert_pks.is_empty() {
243            let write_chunk = insert_chunk.finish();
244            let positions = self.writer.write_chunk(write_chunk).await?;
245
246            for (pk, pos) in insert_pks.into_iter().zip_eq_fast(positions) {
247                let mut index_row_data = Vec::with_capacity(pk_indices.len() + 2);
248                for datum in pk.iter() {
249                    index_row_data.push(datum);
250                }
251                index_row_data.push(Some(ScalarRefImpl::Utf8(&pos.path)));
252                index_row_data.push(Some(ScalarRefImpl::Int64(pos.pos)));
253                self.pk_index_state_table.insert(index_row_data.as_slice());
254            }
255        }
256
257        self.delete_position_buffer = Some(delete_position_buffer);
258        self.pk_index_state_table.try_flush().await?;
259    }
260
261    async fn apply_resolver_chunk(&mut self, chunk: StreamChunk) -> StreamExecutorResult<()> {
262        for (op, row) in chunk.rows() {
263            if op != Op::Insert {
264                bail!(
265                    "iceberg pk-index writer {} expected resolver inserts, got {op:?}",
266                    self.sink_id
267                );
268            }
269            self.pk_index_state_table.insert(row);
270        }
271        self.pk_index_state_table.try_flush().await?;
272        Ok(())
273    }
274
275    #[try_stream(ok = Message, error = StreamExecutorError)]
276    async fn checkpoint_barrier(&mut self, barrier: Barrier) {
277        barrier.assume_no_update_vnode_bitmap(self.ctx.id)?;
278        let mut metadata = None;
279        if barrier.is_checkpoint() {
280            if let Some(chunk) = self
281                .delete_position_buffer
282                .take()
283                .and_then(|mut builder| builder.consume_all())
284            {
285                yield Message::Chunk(chunk.into());
286            }
287            metadata = self.writer.flush().await?;
288        }
289
290        self.pk_index_state_table
291            .commit_assert_no_update_vnode_bitmap(barrier.epoch)
292            .await?;
293        if let Some(metadata) = metadata
294            && metadata.metadata.is_some()
295        {
296            self.local_barrier_manager
297                .report_iceberg_pk_index_sink_metadata(
298                    barrier.epoch,
299                    self.sink_id,
300                    self.ctx.id,
301                    PbIcebergPkIndexSinkRole::Writer,
302                    Some(metadata),
303                );
304        }
305        yield Message::Barrier(barrier);
306    }
307
308    fn validate_compaction_barrier(
309        &self,
310        barrier: &Barrier,
311        expected_task: IcebergCompactionTaskId,
312        expected_phase: Phase,
313        expected_prev: u64,
314    ) -> StreamExecutorResult<()> {
315        if !barrier.is_checkpoint() || barrier.epoch.prev != expected_prev {
316            bail!(
317                "iceberg pk-index writer {} expected checkpoint {:?} starting at {}, got {:?}",
318                self.sink_id,
319                expected_phase,
320                expected_prev,
321                barrier
322            );
323        }
324        match barrier.iceberg_pk_index_compaction() {
325            Some(context)
326                if context.sink_id == self.sink_id
327                    && context.task_id == expected_task
328                    && context.phase == expected_phase as i32 =>
329            {
330                Ok(())
331            }
332            _ => bail!(
333                "iceberg pk-index writer {} expected matching {:?} context for task {}, got {:?}",
334                self.sink_id,
335                expected_phase,
336                expected_task,
337                barrier
338            ),
339        }
340    }
341
342    fn validate_aligned_barriers(
343        &self,
344        left: &Barrier,
345        right: &Barrier,
346    ) -> StreamExecutorResult<()> {
347        if left.epoch != right.epoch || left.kind != right.kind || left.mutation != right.mutation {
348            bail!(
349                "iceberg pk-index writer {} received mismatched left/right barriers: left={:?}, right={:?}",
350                self.sink_id,
351                left,
352                right
353            );
354        }
355        Ok(())
356    }
357
358    fn compaction_begin(
359        &self,
360        barrier: &Barrier,
361    ) -> StreamExecutorResult<Option<IcebergCompactionTaskId>> {
362        let context = match barrier.iceberg_pk_index_compaction() {
363            Some(context) if context.sink_id == self.sink_id => context,
364            _ => return Ok(None),
365        };
366        if context.phase == Phase::End as i32 {
367            bail!(
368                "iceberg pk-index writer {} received unexpected End in Normal mode for task {}",
369                self.sink_id,
370                context.task_id
371            );
372        }
373        if context.phase != Phase::Begin as i32 {
374            bail!(
375                "iceberg pk-index writer {} expected Begin context for task {}, got {:?}",
376                self.sink_id,
377                context.task_id,
378                context.phase
379            );
380        }
381        Ok(Some(context.task_id))
382    }
383
384    #[try_stream(ok = Message, error = StreamExecutorError)]
385    async fn execute_inner(mut self) {
386        let mut input = self.input.take().unwrap().execute();
387        let mut resolver_input = self.resolver_input.take().unwrap().execute();
388
389        // Consume the first barrier.
390        let barrier = expect_first_barrier(&mut input).await?;
391        let remap_first = expect_first_barrier(&mut resolver_input).await?;
392        self.validate_aligned_barriers(&barrier, &remap_first)?;
393        let first_epoch = barrier.epoch;
394        yield Message::Barrier(barrier);
395        self.pk_index_state_table.init_epoch(first_epoch).await?;
396
397        loop {
398            let mode = std::mem::replace(&mut self.mode, WriterInputMode::Normal);
399            match mode {
400                WriterInputMode::Normal => {
401                    let mut completed = false;
402                    #[for_await]
403                    for msg in self.execute_normal(&mut input, &mut resolver_input, &mut completed)
404                    {
405                        yield msg?;
406                    }
407                    if completed {
408                        break;
409                    }
410                }
411                WriterInputMode::ResolvingRight {
412                    task_id,
413                    begin_epoch,
414                } => {
415                    #[for_await]
416                    for msg in
417                        self.execute_resolving_right(&mut resolver_input, task_id, begin_epoch)
418                    {
419                        yield msg?;
420                    }
421                }
422                WriterInputMode::AligningReplacementInput { task_id, barrier } => {
423                    #[for_await]
424                    for msg in self.execute_aligning_replacement_input(&mut input, task_id, barrier)
425                    {
426                        yield msg?;
427                    }
428                }
429            }
430        }
431    }
432
433    #[try_stream(ok = Message, error = StreamExecutorError)]
434    async fn execute_normal<'a>(
435        &'a mut self,
436        input: &'a mut BoxedMessageStream,
437        resolver_input: &'a mut BoxedMessageStream,
438        completed: &'a mut bool,
439    ) {
440        #[for_await]
441        for msg in input {
442            match msg? {
443                Message::Chunk(chunk) =>
444                {
445                    #[for_await]
446                    for chunk in self.process_chunk(chunk) {
447                        yield Message::Chunk(chunk?.into());
448                    }
449                }
450                Message::Watermark(watermark) => {
451                    yield Message::Watermark(watermark);
452                }
453                Message::Barrier(barrier) => {
454                    let msg = next_msg(resolver_input).await?;
455                    let remap_barrier = match msg {
456                        Message::Barrier(b) => b,
457                        _ => bail!(
458                            "iceberg pk-index writer {} expected barrier on remap input, got {msg:?}",
459                            self.sink_id
460                        ),
461                    };
462                    self.validate_aligned_barriers(&barrier, &remap_barrier)?;
463                    let begin = self.compaction_begin(&barrier)?;
464                    let epoch = barrier.epoch;
465                    #[for_await]
466                    for msg in self.checkpoint_barrier(barrier) {
467                        yield msg?;
468                    }
469
470                    if let Some(task_id) = begin {
471                        self.mode = WriterInputMode::ResolvingRight {
472                            task_id,
473                            begin_epoch: epoch,
474                        };
475                        return Ok(());
476                    }
477                }
478            }
479        }
480
481        *completed = true;
482    }
483
484    #[try_stream(ok = Message, error = StreamExecutorError)]
485    async fn execute_resolving_right<'a>(
486        &'a mut self,
487        resolver_input: &'a mut BoxedMessageStream,
488        task_id: IcebergCompactionTaskId,
489        begin_epoch: EpochPair,
490    ) {
491        #[for_await]
492        for msg in resolver_input {
493            match msg? {
494                Message::Chunk(chunk) => {
495                    self.apply_resolver_chunk(chunk).await?;
496                }
497                Message::Watermark(_) => bail!(
498                    "iceberg pk-index writer {} received watermark on remap input while resolving task {}",
499                    self.sink_id,
500                    task_id
501                ),
502                Message::Barrier(barrier) => {
503                    self.validate_compaction_barrier(
504                        &barrier,
505                        task_id,
506                        Phase::End,
507                        begin_epoch.curr,
508                    )?;
509                    self.mode = WriterInputMode::AligningReplacementInput { task_id, barrier };
510                    return Ok(());
511                }
512            }
513        }
514
515        bail!(
516            "iceberg pk-index writer {} resolver input closed before switch-to-input for task {}",
517            self.sink_id,
518            task_id
519        );
520    }
521
522    #[try_stream(ok = Message, error = StreamExecutorError)]
523    async fn execute_aligning_replacement_input<'a>(
524        &'a mut self,
525        input: &'a mut BoxedMessageStream,
526        task_id: IcebergCompactionTaskId,
527        expected: Barrier,
528    ) {
529        #[for_await]
530        for msg in input {
531            match msg? {
532                Message::Chunk(_) => bail!(
533                    "iceberg pk-index writer {} received a chunk from replacement input before its initial barrier for task {}",
534                    self.sink_id,
535                    task_id
536                ),
537                Message::Watermark(_) => bail!(
538                    "iceberg pk-index writer {} received watermark from replacement input before its initial barrier for task {}",
539                    self.sink_id,
540                    task_id
541                ),
542                Message::Barrier(barrier) => {
543                    self.validate_aligned_barriers(&barrier, &expected)?;
544                    #[for_await]
545                    for msg in self.checkpoint_barrier(barrier) {
546                        yield msg?;
547                    }
548                    self.mode = WriterInputMode::Normal;
549                    return Ok(());
550                }
551            }
552        }
553
554        bail!(
555            "iceberg pk-index writer {} replacement input closed before its initial barrier for task {}",
556            self.sink_id,
557            task_id
558        );
559    }
560}
561
562impl<S, W> Execute for WriterExecutor<S, W>
563where
564    S: StateStore,
565    W: IcebergWriter,
566{
567    fn execute(self: Box<Self>) -> BoxedMessageStream {
568        self.execute_inner().boxed()
569    }
570}
571
572async fn next_msg(input: &mut BoxedMessageStream) -> StreamExecutorResult<Message> {
573    input
574        .next()
575        .await
576        .ok_or_else(|| {
577            StreamExecutorError::channel_closed(
578                "iceberg pk-index writer input channel closed unexpectedly",
579            )
580        })
581        .flatten()
582}
583
584#[cfg(test)]
585#[path = "writer_test.rs"]
586mod tests;