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 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/// Input-selection state for the writer's dual-input compaction protocol.
56#[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/// Trait abstracting the Iceberg data file writing for testability.
70///
71/// Implementations are responsible for writing rows to Iceberg data files
72/// and tracking row positions. Commit is handled by the executor, not the writer.
73#[async_trait::async_trait]
74pub trait IcebergWriter: Send + 'static {
75    /// Write a batch of insert rows. Returns the position of each row in the chunk (in order).
76    async fn write_chunk(
77        &mut self,
78        chunk: DataChunk,
79    ) -> StreamExecutorResult<Vec<PositionDeleteInput>>;
80
81    /// Flush current data files on barrier. Returns serialized commit metadata,
82    /// or `None` if no data was written since the last flush.
83    async fn flush(&mut self) -> StreamExecutorResult<Option<SinkMetadata>>;
84}
85
86/// Writer Executor for iceberg pk-index sink with PK index
87///
88/// This stateful executor maintains a PK index that maps primary key values to
89/// their position in data files (`file_path`, `position`). It processes change logs
90/// from upstream:
91///
92/// - **Insert**: Writes the row to a data file via [`IcebergWriter`], records the
93///   position in the PK index state table.
94/// - **Delete**: Looks up the PK index to find the data file position, emits a
95///   delete position message downstream to the position-delete merger, removes from index.
96/// - **Update**: Treated as Delete + Insert. The planner guarantees the old and
97///   new rows share the same PK, so the executor can reuse the projected PK from
98///   the old row when updating the PK index.
99pub 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    /// Column indices of the primary key in the input schema.
108    pk_indices: Vec<usize>,
109    /// State table storing the PK index: `pk_columns` -> (data file, `position`).
110    /// Schema: [`pk_col_0`, ..., `pk_col_n`, data file, `position`: Int64], where the data file
111    /// column is `file_path`: Varchar in the legacy layout, or `file_uuid`: Bytea otherwise.
112    pk_index_state_table: StateTable<S>,
113    /// The Iceberg data file writer.
114    writer: W,
115    /// Rebuilds data file paths from the uuids stored in the PK index. `None` for the legacy
116    /// layout, where the PK index stores full paths.
117    data_file_locator: Option<PkIndexDataFileLocator>,
118    /// Buffer for accumulating delete position messages before the next barrier flush.
119    delete_position_buffer: Option<DataChunkBuilder>,
120    mode: WriterInputMode,
121    chunk_size: usize,
122    /// Maximum number of PK index lookups in flight at the same time.
123    lookup_concurrency: usize,
124    /// Maximum number of input rows merged into one batch from already-buffered chunks.
125    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    /// Removes `index_row` from the PK index and records its Iceberg position as a position delete.
169    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    // Process a batch of stream chunks:
196    //
197    // 1. Compact all chunks together by `pk_indices` so each PK appears in at most one surviving
198    //    change, and any `+/-` cancellations across the batch are absorbed up front. After this
199    //    step every record is a standalone `Insert`, `Delete`, or `Update {old, new}`. A PK whose
200    //    old and new rows are not adjacent shows up as a `Delete` followed later by an `Insert`.
201    // 2. Look up `pk_index_state_table` for every `Delete` / `Update` concurrently. Lookups do not
202    //    depend on each other because PKs are unique after compaction, and none of them can observe
203    //    writes from this batch because the index is only modified in the next steps.
204    // 3. Walk the records in order: emit a position delete and clear the index entry for every
205    //    found row, and buffer `Insert` / `Update::new` rows into a single batched write.
206    // 4. Write all buffered inserts in one `write_chunk` call and persist the returned Iceberg
207    //    positions back to `pk_index_state_table`.
208    //
209    // `pk_index_state_table` and `delete_position_buffer` live until the next barrier, so a later
210    // batch in the same checkpoint observes earlier writes/deletes via the state table.
211    #[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        // Invariant: every input column is visible and written to Iceberg verbatim. The planner
217        // (`promote_iceberg_pk_index_stream_key` in `stream_sink.rs`) enforces this by promoting
218        // hidden stream-key columns to visible and by not adding the extra partition column for
219        // pk-index sinks, so the writer has no hidden-column projection and writes the whole row.
220        let data_types = first.data_types();
221        // An upper bound on appended rows: each surviving record contributes at most one row
222        // (Insert / Update::new), and `records()` yields at most `capacity` records per chunk.
223        let insert_capacity = chunks.iter().map(|c| c.capacity()).sum::<usize>() + 1;
224
225        // `StreamSink::sink_type_in_prop` maps Iceberg's `type='upsert'` to a retract sink,
226        // and `derive_sink_type` rejects upsert input. Only retract or append-only input can
227        // reach this writer, so inserts cannot replace existing Iceberg rows.
228        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        // The compactor groups by `pk_indices`, so `Update`'s old and new rows share the same PK.
237        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            // Rows of one write mostly land in the same data file, so reuse the last parsed uuid.
288            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        // Consume the first barrier.
448        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        // The next input item that has been polled while batching but not yet handled.
499        // `Some(None)` means the input has ended.
500        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                    // Merge chunks that are already buffered so their PK lookups run in one
512                    // concurrent round. Never wait for more input here: a backlogged input fills
513                    // the batch, while an idle one adds no latency.
514                    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
656/// Looks up the PK index rows of `pks` with at most `concurrency` lookups in flight.
657/// Results are returned in the same order as `pks`.
658async 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    // `buffer_unordered` keeps a slow lookup from blocking the others; results are put back by
665    // index to keep the output deterministic.
666    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;