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                    barrier.assume_no_update_vnode_bitmap(self.ctx.id)?;
261
262                    let mut metadata = None;
263                    if barrier.is_checkpoint() {
264                        if let Some(chunk) = self
265                            .delete_position_buffer
266                            .take()
267                            .and_then(|mut b| b.consume_all())
268                        {
269                            yield Message::Chunk(chunk.into());
270                        }
271                        metadata = self.writer.flush().await?;
272                    }
273
274                    self.pk_index_state_table
275                        .commit_assert_no_update_vnode_bitmap(barrier.epoch)
276                        .await?;
277
278                    if let Some(metadata) = metadata
279                        && metadata.metadata.is_some()
280                    {
281                        self.local_barrier_manager
282                            .report_iceberg_pk_index_sink_metadata(
283                                barrier.epoch,
284                                self.sink_id,
285                                self.ctx.id,
286                                PbIcebergPkIndexSinkRole::Writer,
287                                Some(metadata),
288                            );
289                    }
290
291                    yield Message::Barrier(barrier);
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)]
312#[path = "writer_test.rs"]
313mod tests;