risingwave_stream/executor/source/batch_source/
batch_iceberg_fetch.rs1use 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
40type FileEntry = (String, JsonbVal);
42
43struct FetchState {
44 is_refreshing: bool,
46
47 is_list_finished: bool,
49
50 splits_on_fetch: usize,
52
53 is_batch_finished: Arc<RwLock<bool>>,
55
56 file_queue: VecDeque<FileEntry>,
58
59 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 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 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 fn mark_refresh_complete(&mut self) {
103 self.is_list_finished = false;
104 self.is_refreshing = false;
105 }
106
107 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 fn mark_file_fetched(&mut self) {
115 self.splits_on_fetch -= 1;
116
117 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 fn handle_error_recovery(&mut self) {
129 if !self.in_flight_files.is_empty() {
130 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 fn enqueue_files(&mut self, files: impl IntoIterator<Item = FileEntry>) {
141 self.file_queue.extend(files);
142 }
143}
144
145struct 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
178pub struct BatchIcebergFetchExecutor<S: StateStore> {
198 actor_ctx: ActorContextRef,
199
200 stream_source_core: Option<StreamSourceCore<S>>,
202
203 upstream: Option<Executor>,
205
206 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 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 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 let column_indices = ColumnIndices::from_source_desc(&source_desc);
259
260 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 let mut state = FetchState::new();
288 let mut stream = StreamReaderWithPause::<true, ChunksWithState>::new(
289 upstream,
290 stream::pending().boxed(),
291 );
292
293 while let Some(msg) = stream.next().await {
295 match msg {
296 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 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 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 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 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 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 state.in_flight_files.clear();
475
476 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 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 #[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(), 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}