Skip to main content

risingwave_stream/executor/source/batch_source/
batch_iceberg_fetch.rs

1// Copyright 2025 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 std::collections::VecDeque;
16
17use either::Either;
18use futures::stream;
19use iceberg::scan::FileScanTask;
20use itertools::Itertools;
21use parking_lot::RwLock;
22use risingwave_common::array::Op;
23use risingwave_common::catalog::{ICEBERG_FILE_PATH_COLUMN_NAME, ICEBERG_FILE_POS_COLUMN_NAME};
24use risingwave_common::config::StreamingConfig;
25use risingwave_common::id::TableId;
26use risingwave_common::metrics::GLOBAL_ERROR_METRICS;
27use risingwave_common::types::{JsonbVal, Scalar, ScalarRef};
28use risingwave_connector::source::iceberg::{
29    GLOBAL_ICEBERG_SCAN_METRICS, IcebergFileScanMetrics, IcebergScanMetricsLabels, IcebergScanOpts,
30    PersistedFileScanTask, scan_task_to_chunk_with_deletes,
31};
32use risingwave_connector::source::reader::desc::SourceDesc;
33use thiserror_ext::AsReport;
34
35use crate::executor::prelude::*;
36use crate::executor::source::{ChunksWithState, StreamSourceCore, prune_additional_cols};
37use crate::executor::stream_reader::StreamReaderWithPause;
38use crate::task::LocalBarrierManager;
39
40/// Type alias for file entries in the queue: (`file_name`, `scan_task_json`)
41type FileEntry = (String, JsonbVal);
42
43struct FetchState {
44    /// Whether we are in a refresh cycle (started by `RefreshStart`, ended by load finished report)
45    is_refreshing: bool,
46
47    /// Whether the upstream list executor has finished listing all files
48    is_list_finished: bool,
49
50    /// Number of files currently being fetched in the active batch reader
51    splits_on_fetch: usize,
52
53    /// Shared flag indicating whether the current batch reader has finished reading all files
54    is_batch_finished: Arc<RwLock<bool>>,
55
56    /// Queue of files waiting to be processed
57    file_queue: VecDeque<FileEntry>,
58
59    /// Files currently being fetched by the batch reader.
60    /// Used for at-least-once recovery: on error, these files are re-queued.
61    in_flight_files: Vec<FileEntry>,
62}
63
64impl FetchState {
65    fn new() -> Self {
66        Self {
67            is_refreshing: false,
68            is_list_finished: false,
69            splits_on_fetch: 0,
70            is_batch_finished: Arc::new(RwLock::new(false)),
71            file_queue: VecDeque::new(),
72            in_flight_files: Vec::new(),
73        }
74    }
75
76    /// Reset all state for a new refresh cycle.
77    fn reset_for_refresh(&mut self) {
78        tracing::info!(
79            "reset_for_refresh: clearing file_queue_len={}, in_flight_files_len={}, splits_on_fetch={}",
80            self.file_queue.len(),
81            self.in_flight_files.len(),
82            self.splits_on_fetch
83        );
84        self.file_queue.clear();
85        self.in_flight_files.clear();
86        self.splits_on_fetch = 0;
87        self.is_refreshing = true;
88        self.is_list_finished = false;
89        *self.is_batch_finished.write() = false;
90    }
91
92    /// Check if we should report load finished to the barrier manager.
93    fn should_report_load_finished(&self) -> bool {
94        self.splits_on_fetch == 0
95            && self.file_queue.is_empty()
96            && self.in_flight_files.is_empty()
97            && self.is_list_finished
98            && self.is_refreshing
99    }
100
101    /// Mark the refresh cycle as complete after reporting load finished.
102    fn mark_refresh_complete(&mut self) {
103        self.is_list_finished = false;
104        self.is_refreshing = false;
105    }
106
107    /// Check if we should start a new batch reader.
108    fn should_start_batch_reader(&self, need_rebuild: bool) -> bool {
109        need_rebuild
110            || (self.splits_on_fetch == 0 && !self.file_queue.is_empty() && self.is_refreshing)
111    }
112
113    /// Mark one file as successfully fetched.
114    fn mark_file_fetched(&mut self) {
115        self.splits_on_fetch -= 1;
116
117        // When all files in the current batch complete successfully, clear in-flight tracking.
118        // We don't need to wait for is_batch_finished because by the time splits_on_fetch reaches 0,
119        // all files have been processed successfully.
120        if self.splits_on_fetch == 0 {
121            tracing::info!("All files fetched successfully, clearing in_flight_files");
122            self.in_flight_files.clear();
123        }
124    }
125
126    /// Handle fetch error with at-least-once recovery.
127    /// Re-queues in-flight files to ensure no file is skipped.
128    fn handle_error_recovery(&mut self) {
129        if !self.in_flight_files.is_empty() {
130            // Re-queue in-flight files to the front (reverse to maintain original order)
131            for file in self.in_flight_files.drain(..).rev() {
132                self.file_queue.push_front(file);
133            }
134        }
135        self.splits_on_fetch = 0;
136        *self.is_batch_finished.write() = false;
137    }
138
139    /// Enqueue new file assignments from upstream.
140    fn enqueue_files(&mut self, files: impl IntoIterator<Item = FileEntry>) {
141        self.file_queue.extend(files);
142    }
143}
144
145// ============================================================================
146// Column Indices Helper
147// ============================================================================
148
149/// Indices of special columns that need to be pruned from output.
150struct ColumnIndices {
151    file_path_idx: usize,
152    file_pos_idx: usize,
153}
154
155impl ColumnIndices {
156    fn from_source_desc(source_desc: &SourceDesc) -> Self {
157        let file_path_idx = source_desc
158            .columns
159            .iter()
160            .position(|c| c.name == ICEBERG_FILE_PATH_COLUMN_NAME)
161            .expect("file path column not found");
162        let file_pos_idx = source_desc
163            .columns
164            .iter()
165            .position(|c| c.name == ICEBERG_FILE_POS_COLUMN_NAME)
166            .expect("file pos column not found");
167        Self {
168            file_path_idx,
169            file_pos_idx,
170        }
171    }
172
173    fn to_prune(&self) -> [usize; 2] {
174        [self.file_path_idx, self.file_pos_idx]
175    }
176}
177
178// ============================================================================
179// Batch Iceberg Fetch Executor
180// ============================================================================
181
182/// Executor that fetches data from Iceberg files discovered by an upstream list executor.
183///
184///
185/// # Refresh Cycle
186///
187/// 1. Receives `RefreshStart` mutation - clears state and starts new cycle
188/// 2. Receives file chunks from upstream list executor - queues files for processing
189/// 3. On each barrier, starts batch reader if files are pending
190/// 4. Receives `ListFinish` mutation - marks listing as complete
191/// 5. When all files processed, reports load finished
192///
193/// # At-Least-Once Semantics
194///
195/// On fetch errors, in-flight files are re-queued to ensure no file is skipped.
196/// This may cause duplicate reads, but guarantees data completeness.
197pub struct BatchIcebergFetchExecutor<S: StateStore> {
198    actor_ctx: ActorContextRef,
199
200    /// Core component for managing external streaming source state
201    stream_source_core: Option<StreamSourceCore<S>>,
202
203    /// Upstream list executor that provides file scan tasks
204    upstream: Option<Executor>,
205
206    /// Barrier manager for reporting load finished
207    barrier_manager: LocalBarrierManager,
208
209    streaming_config: Arc<StreamingConfig>,
210
211    associated_table_id: TableId,
212
213    scan_metrics: Option<IcebergScanMetricsLabels>,
214    file_scan_metrics: Option<IcebergFileScanMetrics>,
215}
216
217impl<S: StateStore> BatchIcebergFetchExecutor<S> {
218    pub fn new(
219        actor_ctx: ActorContextRef,
220        stream_source_core: StreamSourceCore<S>,
221        upstream: Executor,
222        barrier_manager: LocalBarrierManager,
223        streaming_config: Arc<StreamingConfig>,
224        associated_table_id: Option<TableId>,
225    ) -> Self {
226        assert!(associated_table_id.is_some());
227        Self {
228            actor_ctx,
229            stream_source_core: Some(stream_source_core),
230            upstream: Some(upstream),
231            barrier_manager,
232            streaming_config,
233            associated_table_id: associated_table_id.unwrap(),
234            scan_metrics: None,
235            file_scan_metrics: None,
236        }
237    }
238}
239
240impl<S: StateStore> BatchIcebergFetchExecutor<S> {
241    #[try_stream(ok = Message, error = StreamExecutorError)]
242    async fn into_stream(mut self) {
243        // Initialize upstream and wait for first barrier
244        let mut upstream = self.upstream.take().unwrap().execute();
245        let first_barrier = expect_first_barrier(&mut upstream).await?;
246        yield Message::Barrier(first_barrier);
247
248        // Initialize source description
249        let mut core = self.stream_source_core.take().unwrap();
250        let source_desc = core
251            .source_desc_builder
252            .take()
253            .unwrap()
254            .build()
255            .map_err(StreamExecutorError::connector_error)?;
256
257        // Find column indices for pruning
258        let column_indices = ColumnIndices::from_source_desc(&source_desc);
259
260        // Initialize metrics context
261        let iceberg_metrics = &GLOBAL_ICEBERG_SCAN_METRICS;
262        let iceberg_table_name = match &source_desc.source.config {
263            risingwave_connector::source::ConnectorProperties::Iceberg(props) => {
264                props.table.table_name().to_owned()
265            }
266            _ => unreachable!("BatchIcebergFetchExecutor must be built with Iceberg properties"),
267        };
268        let source_id_str = core.source_id.to_string();
269        let source_name_str = core.source_name.clone();
270        let scan_metrics = self
271            .scan_metrics
272            .insert(IcebergScanMetricsLabels::new(
273                source_id_str,
274                source_name_str,
275                iceberg_table_name.clone(),
276            ))
277            .clone();
278        let file_scan_metrics = self
279            .file_scan_metrics
280            .insert(IcebergFileScanMetrics::new(
281                iceberg_metrics,
282                &iceberg_table_name,
283            ))
284            .clone();
285
286        // Initialize state and stream reader
287        let mut state = FetchState::new();
288        let mut stream = StreamReaderWithPause::<true, ChunksWithState>::new(
289            upstream,
290            stream::pending().boxed(),
291        );
292
293        // Main processing loop
294        while let Some(msg) = stream.next().await {
295            match msg {
296                // ----- Error Handling with At-Least-Once Recovery -----
297                Err(e) => {
298                    tracing::error!(error = %e.as_report(), "Fetch Error");
299
300                    GLOBAL_ERROR_METRICS.user_source_error.report([
301                        e.variant_name().to_owned(),
302                        core.source_id.to_string(),
303                        self.actor_ctx.fragment_id.to_string(),
304                        self.associated_table_id.to_string(),
305                    ]);
306
307                    scan_metrics.record_fetch_error();
308
309                    let in_flight_count = state.in_flight_files.len();
310                    state.handle_error_recovery();
311
312                    if in_flight_count > 0 {
313                        tracing::info!(
314                            source_id = %core.source_id,
315                            table_id = %self.associated_table_id,
316                            in_flight_count = %in_flight_count,
317                            "re-queued in-flight files for retry to ensure at-least-once semantics"
318                        );
319                    }
320
321                    stream.replace_data_stream(stream::pending().boxed());
322
323                    tracing::info!(
324                        source_id = %core.source_id,
325                        table_id = %self.associated_table_id,
326                        remaining_files = %state.file_queue.len(),
327                        "attempting to recover from fetch error, will retry on next barrier"
328                    );
329
330                    continue;
331                }
332
333                // ----- Upstream Messages (barriers, file assignments) -----
334                Ok(Either::Left(msg)) => match msg {
335                    Message::Barrier(barrier) => {
336                        let need_rebuild = Self::handle_barrier_mutations(
337                            &barrier,
338                            &core,
339                            &mut state,
340                            &mut stream,
341                        );
342
343                        if barrier.is_checkpoint() && state.should_report_load_finished() {
344                            tracing::info!(
345                                ?barrier.epoch,
346                                actor_id = %self.actor_ctx.id,
347                                source_id = %core.source_id,
348                                table_id = %self.associated_table_id,
349                                "Reporting load finished"
350                            );
351                            self.barrier_manager.report_source_load_finished(
352                                barrier.epoch,
353                                self.actor_ctx.id,
354                                self.associated_table_id,
355                                core.source_id,
356                            );
357                            state.mark_refresh_complete();
358                        }
359
360                        yield Message::Barrier(barrier);
361
362                        if state.should_start_batch_reader(need_rebuild) {
363                            Self::start_batch_reader(
364                                &mut state,
365                                &mut stream,
366                                source_desc.clone(),
367                                &self.streaming_config,
368                                file_scan_metrics.clone(),
369                            )?;
370
371                            scan_metrics.set_inflight_file_count(state.splits_on_fetch);
372                        }
373                    }
374
375                    Message::Chunk(chunk) => {
376                        let files = Self::parse_file_assignments(&chunk);
377                        tracing::debug!("Received {} file assignments from upstream", files.len());
378                        state.enqueue_files(files);
379                    }
380
381                    Message::Watermark(_) => unreachable!(),
382                },
383
384                // ----- Fetched Data from Iceberg Files -----
385                Ok(Either::Right(ChunksWithState { chunks, .. })) => {
386                    state.mark_file_fetched();
387
388                    scan_metrics.set_inflight_file_count(state.splits_on_fetch);
389
390                    for chunk in &chunks {
391                        let pruned = prune_additional_cols(
392                            chunk,
393                            &column_indices.to_prune(),
394                            &source_desc.columns,
395                        );
396                        yield Message::Chunk(pruned);
397                    }
398                }
399            }
400        }
401    }
402
403    /// Handle barrier mutations and return whether reader needs to be rebuilt.
404    fn handle_barrier_mutations(
405        barrier: &Barrier,
406        core: &StreamSourceCore<S>,
407        state: &mut FetchState,
408        stream: &mut StreamReaderWithPause<true, ChunksWithState>,
409    ) -> bool {
410        let Some(mutation) = barrier.mutation.as_deref() else {
411            return false;
412        };
413
414        match mutation {
415            Mutation::Pause => {
416                stream.pause_stream();
417                false
418            }
419            Mutation::Resume => {
420                stream.resume_stream();
421                false
422            }
423            Mutation::RefreshStart {
424                associated_source_id,
425                ..
426            } if associated_source_id == &core.source_id => {
427                tracing::info!(
428                    ?barrier.epoch,
429                    source_id = %core.source_id,
430                    is_checkpoint = barrier.is_checkpoint(),
431                    "RefreshStart: resetting state for new refresh cycle"
432                );
433                state.reset_for_refresh();
434                true
435            }
436            Mutation::ListFinish {
437                associated_source_id,
438            } if associated_source_id == &core.source_id => {
439                tracing::info!(
440                    ?barrier.epoch,
441                    source_id = %core.source_id,
442                    is_checkpoint = barrier.is_checkpoint(),
443                    "ListFinish: upstream finished listing files"
444                );
445                state.is_list_finished = true;
446                false
447            }
448            _ => false,
449        }
450    }
451
452    /// Parse file assignments from an upstream chunk.
453    fn parse_file_assignments(chunk: &StreamChunk) -> Vec<FileEntry> {
454        chunk
455            .data_chunk()
456            .rows()
457            .map(|row| {
458                let file_name = row.datum_at(0).unwrap().into_utf8().to_owned();
459                let scan_task = row.datum_at(1).unwrap().into_jsonb().to_owned_scalar();
460                (file_name, scan_task)
461            })
462            .collect()
463    }
464
465    /// Start a new batch reader for pending files.
466    fn start_batch_reader(
467        state: &mut FetchState,
468        stream: &mut StreamReaderWithPause<true, ChunksWithState>,
469        source_desc: SourceDesc,
470        streaming_config: &StreamingConfig,
471        file_scan_metrics: IcebergFileScanMetrics,
472    ) -> StreamExecutorResult<()> {
473        // Clear previous in-flight files (should already be empty on success, re-queued on error)
474        state.in_flight_files.clear();
475
476        // Collect batch of files to process
477        let batch_size = streaming_config.developer.iceberg_fetch_batch_size as usize;
478        let mut batch = Vec::with_capacity(batch_size);
479
480        for _ in 0..batch_size {
481            let Some(file_entry) = state.file_queue.pop_front() else {
482                break;
483            };
484            // Track as in-flight for at-least-once recovery
485            state.in_flight_files.push(file_entry.clone());
486            batch.push(PersistedFileScanTask::decode(file_entry.1.as_scalar_ref())?);
487        }
488
489        if batch.is_empty() {
490            tracing::info!("Batch is empty, setting stream to pending");
491            stream.replace_data_stream(stream::pending().boxed());
492        } else {
493            tracing::debug!("Starting batch reader with {} files", batch.len());
494            state.splits_on_fetch += batch.len();
495            *state.is_batch_finished.write() = false;
496
497            let batch_reader = Self::build_batched_stream_reader(
498                source_desc,
499                batch,
500                streaming_config.developer.chunk_size,
501                state.is_batch_finished.clone(),
502                file_scan_metrics,
503            );
504            stream.replace_data_stream(batch_reader.boxed());
505        }
506
507        Ok(())
508    }
509
510    /// Build a stream reader that reads multiple Iceberg files in sequence.
511    #[try_stream(ok = ChunksWithState, error = StreamExecutorError)]
512    async fn build_batched_stream_reader(
513        source_desc: SourceDesc,
514        tasks: Vec<FileScanTask>,
515        chunk_size: usize,
516        batch_finished: Arc<RwLock<bool>>,
517        file_scan_metrics: IcebergFileScanMetrics,
518    ) {
519        let properties = match source_desc.source.config.clone() {
520            risingwave_connector::source::ConnectorProperties::Iceberg(props) => props,
521            _ => unreachable!("Expected Iceberg connector properties"),
522        };
523        let table = properties.load_table().await?;
524
525        for task in tasks {
526            let mut chunks = vec![];
527            #[for_await]
528            for chunk_result in scan_task_to_chunk_with_deletes(
529                table.clone(),
530                task,
531                IcebergScanOpts::new(chunk_size, true, true, true),
532                Some(file_scan_metrics.clone()),
533            ) {
534                let chunk = chunk_result?;
535                let ops = itertools::repeat_n(Op::Insert, chunk.capacity()).collect_vec();
536                chunks.push(StreamChunk::from_parts(ops, chunk));
537            }
538
539            yield ChunksWithState {
540                chunks,
541                data_file_path: String::new(), // Not needed for refreshable iceberg fetch
542                last_read_pos: None,
543            };
544        }
545
546        *batch_finished.write() = true;
547    }
548}
549
550impl<S: StateStore> Execute for BatchIcebergFetchExecutor<S> {
551    fn execute(self: Box<Self>) -> BoxedMessageStream {
552        self.into_stream().boxed()
553    }
554}
555
556impl<S: StateStore> Debug for BatchIcebergFetchExecutor<S> {
557    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
558        if let Some(core) = &self.stream_source_core {
559            f.debug_struct("BatchIcebergFetchExecutor")
560                .field("source_id", &core.source_id)
561                .field("column_ids", &core.column_ids)
562                .finish()
563        } else {
564            f.debug_struct("BatchIcebergFetchExecutor").finish()
565        }
566    }
567}