1use std::ops::Bound;
16
17use either::Either;
18use futures::{StreamExt, TryStreamExt, stream};
19use futures_async_stream::try_stream;
20use iceberg::scan::FileScanTask;
21use itertools::Itertools;
22use risingwave_common::array::{DataChunk, Op, SerialArray};
23use risingwave_common::bitmap::Bitmap;
24use risingwave_common::catalog::{
25 ColumnId, ICEBERG_FILE_PATH_COLUMN_NAME, ICEBERG_FILE_POS_COLUMN_NAME, ROW_ID_COLUMN_NAME,
26};
27use risingwave_common::config::StreamingConfig;
28use risingwave_common::hash::VnodeBitmapExt;
29use risingwave_common::id::SourceId;
30use risingwave_common::types::{JsonbVal, ScalarRef, Serial, ToOwnedDatum};
31use risingwave_connector::source::iceberg::{
32 GLOBAL_ICEBERG_SCAN_METRICS, IcebergFileScanMetrics, IcebergScanMetricsLabels, IcebergScanOpts,
33 PersistedFileScanTask, scan_task_to_chunk_with_deletes,
34};
35use risingwave_connector::source::reader::desc::SourceDesc;
36use risingwave_connector::source::{SourceContext, SourceCtrlOpts};
37use risingwave_pb::common::ThrottleType;
38use risingwave_storage::store::PrefetchOptions;
39use thiserror_ext::AsReport;
40
41use super::{SourceStateTableHandler, StreamSourceCore, prune_additional_cols};
42use crate::common::rate_limit::limited_chunk_size;
43use crate::executor::prelude::*;
44use crate::executor::stream_reader::StreamReaderWithPause;
45
46pub struct IcebergFetchExecutor<S: StateStore> {
52 actor_ctx: ActorContextRef,
53
54 stream_source_core: Option<StreamSourceCore<S>>,
56
57 upstream: Option<Executor>,
60
61 rate_limit_rps: Option<u32>,
63
64 streaming_config: Arc<StreamingConfig>,
66
67 scan_metrics: Option<IcebergScanMetricsLabels>,
68 file_scan_metrics: Option<IcebergFileScanMetrics>,
69}
70
71pub(crate) struct ChunksWithState {
76 pub chunks: Vec<StreamChunk>,
78
79 pub data_file_path: String,
81
82 #[expect(dead_code)]
84 pub last_read_pos: Datum,
85}
86
87impl<S: StateStore> IcebergFetchExecutor<S> {
88 pub fn new(
89 actor_ctx: ActorContextRef,
90 stream_source_core: StreamSourceCore<S>,
91 upstream: Executor,
92 rate_limit_rps: Option<u32>,
93 streaming_config: Arc<StreamingConfig>,
94 ) -> Self {
95 Self {
96 actor_ctx,
97 stream_source_core: Some(stream_source_core),
98 upstream: Some(upstream),
99 rate_limit_rps,
100 streaming_config,
101 scan_metrics: None,
102 file_scan_metrics: None,
103 }
104 }
105
106 #[expect(clippy::too_many_arguments)]
107 async fn replace_with_new_batch_reader<const BIASED: bool>(
108 splits_on_fetch: &mut usize,
109 state_store_handler: &SourceStateTableHandler<S>,
110 column_ids: Vec<ColumnId>,
111 source_ctx: SourceContext,
112 source_desc: SourceDesc,
113 stream: &mut StreamReaderWithPause<BIASED, ChunksWithState>,
114 rate_limit_rps: Option<u32>,
115 streaming_config: Arc<StreamingConfig>,
116 file_scan_metrics: IcebergFileScanMetrics,
117 ) -> StreamExecutorResult<()> {
118 let mut batch =
119 Vec::with_capacity(streaming_config.developer.iceberg_fetch_batch_size as usize);
120 let state_table = state_store_handler.state_table();
121 'vnodes: for vnode in state_table.vnodes().iter_vnodes() {
122 let table_iter = state_table
123 .iter_with_vnode(
124 vnode,
125 &(Bound::<OwnedRow>::Unbounded, Bound::<OwnedRow>::Unbounded),
126 PrefetchOptions::prefetch_for_small_range_scan(),
128 )
129 .await?;
130 pin_mut!(table_iter);
131 while let Some(item) = table_iter.next().await {
132 let row = item?;
133 let task = match row.datum_at(1) {
134 Some(ScalarRefImpl::Jsonb(jsonb_ref)) => {
135 PersistedFileScanTask::decode(jsonb_ref)?
136 }
137 _ => unreachable!(),
138 };
139 batch.push(task);
140
141 if batch.len() >= streaming_config.developer.iceberg_fetch_batch_size as usize {
142 break 'vnodes;
143 }
144 }
145 }
146 if batch.is_empty() {
147 stream.replace_data_stream(stream::pending().boxed());
148 } else {
149 *splits_on_fetch += batch.len();
150 let batch_reader = Self::build_batched_stream_reader(
151 column_ids,
152 source_ctx,
153 source_desc,
154 batch,
155 rate_limit_rps,
156 streaming_config,
157 file_scan_metrics,
158 )
159 .map_err(StreamExecutorError::connector_error);
160 stream.replace_data_stream(batch_reader);
161 }
162
163 Ok(())
164 }
165
166 #[try_stream(ok = ChunksWithState, error = StreamExecutorError)]
167 async fn build_batched_stream_reader(
168 _column_ids: Vec<ColumnId>,
169 _source_ctx: SourceContext,
170 source_desc: SourceDesc,
171 batch: Vec<FileScanTask>,
172 _rate_limit_rps: Option<u32>,
173 streaming_config: Arc<StreamingConfig>,
174 file_scan_metrics: IcebergFileScanMetrics,
175 ) {
176 let file_path_idx = source_desc
177 .columns
178 .iter()
179 .position(|c| c.name == ICEBERG_FILE_PATH_COLUMN_NAME)
180 .unwrap();
181 let file_pos_idx = source_desc
182 .columns
183 .iter()
184 .position(|c| c.name == ICEBERG_FILE_POS_COLUMN_NAME)
185 .unwrap();
186 let properties = source_desc.source.config.clone();
187 let properties = match properties {
188 risingwave_connector::source::ConnectorProperties::Iceberg(iceberg_properties) => {
189 iceberg_properties
190 }
191 _ => unreachable!(),
192 };
193 let table = properties.load_table().await?;
194 for task in batch {
195 let task_data_file_path = task.data_file_path.clone();
198 let mut chunks = vec![];
199 #[for_await]
200 for chunk in scan_task_to_chunk_with_deletes(
201 table.clone(),
202 task,
203 IcebergScanOpts {
204 chunk_size: streaming_config.developer.chunk_size,
205 need_seq_num: true, need_file_path_and_pos: true,
207 handle_delete_files: table.metadata().format_version()
211 >= iceberg::spec::FormatVersion::V3,
212 },
213 Some(file_scan_metrics.clone()),
214 ) {
215 let chunk = chunk?;
216 if chunk.cardinality() == 0 {
222 continue;
223 }
224 chunks.push(StreamChunk::from_parts(
225 itertools::repeat_n(Op::Insert, chunk.cardinality()).collect_vec(),
226 chunk,
227 ));
228 }
229 let (data_file_path, last_read_pos) = if let Some(last_chunk) = chunks.last() {
235 let last_row = last_chunk.row_at(last_chunk.cardinality() - 1).1;
236 let path = last_row
237 .datum_at(file_path_idx)
238 .unwrap()
239 .into_utf8()
240 .to_owned();
241 let pos = last_row.datum_at(file_pos_idx).unwrap().to_owned_datum();
242 (path, pos)
243 } else {
244 (task_data_file_path, None)
247 };
248 yield ChunksWithState {
249 chunks,
250 data_file_path,
251 last_read_pos,
252 };
253 }
254 }
255
256 fn build_source_ctx(
257 &self,
258 source_desc: &SourceDesc,
259 source_id: SourceId,
260 source_name: &str,
261 ) -> SourceContext {
262 SourceContext::new(
263 self.actor_ctx.id,
264 source_id,
265 self.actor_ctx.fragment_id,
266 source_name.to_owned(),
267 source_desc.metrics.clone(),
268 SourceCtrlOpts {
269 chunk_size: limited_chunk_size(self.rate_limit_rps),
270 split_txn: self.rate_limit_rps.is_some(), },
272 source_desc.source.config.clone(),
273 None,
274 )
275 }
276
277 #[try_stream(ok = Message, error = StreamExecutorError)]
278 async fn into_stream(mut self) {
279 let mut upstream = self.upstream.take().unwrap().execute();
280 let barrier = expect_first_barrier(&mut upstream).await?;
281 let first_epoch = barrier.epoch;
282 let is_pause_on_startup = barrier.is_pause_on_startup();
283 yield Message::Barrier(barrier);
284
285 let mut core = self.stream_source_core.take().unwrap();
286 let mut state_store_handler = core.split_state_store;
287
288 let source_desc_builder = core.source_desc_builder.take().unwrap();
290
291 let source_desc = source_desc_builder
292 .build()
293 .map_err(StreamExecutorError::connector_error)?;
294
295 let file_path_idx = source_desc
296 .columns
297 .iter()
298 .position(|c| c.name == ICEBERG_FILE_PATH_COLUMN_NAME)
299 .unwrap();
300 let file_pos_idx = source_desc
301 .columns
302 .iter()
303 .position(|c| c.name == ICEBERG_FILE_POS_COLUMN_NAME)
304 .unwrap();
305 let row_id_idx = source_desc
307 .columns
308 .iter()
309 .position(|c| c.name == ROW_ID_COLUMN_NAME)
310 .unwrap();
311 tracing::trace!(
312 "source_desc.columns: {:#?}, file_path_idx: {}, file_pos_idx: {}, row_id_idx: {}",
313 source_desc.columns,
314 file_path_idx,
315 file_pos_idx,
316 row_id_idx
317 );
318 state_store_handler.init_epoch(first_epoch).await?;
320
321 let iceberg_table_name = {
323 match &source_desc.source.config {
324 risingwave_connector::source::ConnectorProperties::Iceberg(props) => {
325 props.table.table_name().to_owned()
326 }
327 _ => unreachable!("IcebergFetchExecutor must be built with Iceberg properties"),
328 }
329 };
330 let source_id_str = core.source_id.to_string();
331 let source_name_str = core.source_name.clone();
332 let scan_metrics = self
333 .scan_metrics
334 .insert(IcebergScanMetricsLabels::new(
335 source_id_str,
336 source_name_str,
337 iceberg_table_name.clone(),
338 ))
339 .clone();
340 let file_scan_metrics = self
341 .file_scan_metrics
342 .insert(IcebergFileScanMetrics::new(
343 &GLOBAL_ICEBERG_SCAN_METRICS,
344 &iceberg_table_name,
345 ))
346 .clone();
347
348 let mut splits_on_fetch: usize = 0;
349 let mut stream = StreamReaderWithPause::<true, ChunksWithState>::new(
350 upstream,
351 stream::pending().boxed(),
352 );
353
354 if is_pause_on_startup {
355 stream.pause_stream();
356 }
357
358 Self::replace_with_new_batch_reader(
362 &mut splits_on_fetch,
363 &state_store_handler, core.column_ids.clone(),
365 self.build_source_ctx(&source_desc, core.source_id, &core.source_name),
366 source_desc.clone(),
367 &mut stream,
368 self.rate_limit_rps,
369 self.streaming_config.clone(),
370 file_scan_metrics.clone(),
371 )
372 .await?;
373 scan_metrics.set_inflight_file_count(splits_on_fetch);
374
375 while let Some(msg) = stream.next().await {
376 match msg {
377 Err(e) => {
378 tracing::error!(error = %e.as_report(), "Fetch Error");
379 scan_metrics.record_fetch_error();
380 splits_on_fetch = 0;
381 scan_metrics.set_inflight_file_count(0);
382 }
383 Ok(msg) => {
384 match msg {
385 Either::Left(msg) => {
387 match msg {
388 Message::Barrier(barrier) => {
389 let mut need_rebuild_reader = false;
390
391 if let Some(mutation) = barrier.mutation.as_deref() {
392 match mutation {
393 Mutation::Pause => stream.pause_stream(),
394 Mutation::Resume => stream.resume_stream(),
395 Mutation::Throttle(fragment_to_apply) => {
396 if let Some(entry) = fragment_to_apply
397 .get(&self.actor_ctx.fragment_id)
398 && entry.throttle_type() == ThrottleType::Source
399 && entry.rate_limit != self.rate_limit_rps
400 {
401 tracing::debug!(
402 "updating rate limit from {:?} to {:?}",
403 self.rate_limit_rps,
404 entry.rate_limit
405 );
406 self.rate_limit_rps = entry.rate_limit;
407 need_rebuild_reader = true;
408 }
409 }
410 _ => (),
411 }
412 }
413
414 let post_commit = state_store_handler
415 .commit_may_update_vnode_bitmap(barrier.epoch)
416 .await?;
417
418 let update_vnode_bitmap =
419 barrier.as_update_vnode_bitmap(self.actor_ctx.id);
420 yield Message::Barrier(barrier);
422
423 if post_commit
424 .post_yield_barrier(update_vnode_bitmap)
425 .await?
426 .is_some()
427 {
428 splits_on_fetch = 0;
432 }
433
434 if splits_on_fetch == 0 || need_rebuild_reader {
435 Self::replace_with_new_batch_reader(
436 &mut splits_on_fetch,
437 &state_store_handler,
438 core.column_ids.clone(),
439 self.build_source_ctx(
440 &source_desc,
441 core.source_id,
442 &core.source_name,
443 ),
444 source_desc.clone(),
445 &mut stream,
446 self.rate_limit_rps,
447 self.streaming_config.clone(),
448 file_scan_metrics.clone(),
449 )
450 .await?;
451 scan_metrics.set_inflight_file_count(splits_on_fetch);
452 }
453 }
454 Message::Chunk(chunk) => {
457 let jsonb_values: Vec<(String, JsonbVal)> = chunk
458 .data_chunk()
459 .rows()
460 .map(|row| {
461 let file_name = row.datum_at(0).unwrap().into_utf8();
462 let split = row.datum_at(1).unwrap().into_jsonb();
463 (file_name.to_owned(), split.to_owned_scalar())
464 })
465 .collect();
466 state_store_handler.set_states_json(jsonb_values).await?;
467 state_store_handler.try_flush().await?;
468 }
469 Message::Watermark(_) => unreachable!(),
470 }
471 }
472 Either::Right(ChunksWithState {
474 chunks,
475 data_file_path,
476 last_read_pos: _,
477 }) => {
478 if true {
480 splits_on_fetch = splits_on_fetch.saturating_sub(1);
481 state_store_handler.delete(&data_file_path).await?;
482 scan_metrics.set_inflight_file_count(splits_on_fetch);
483 }
484
485 for chunk in &chunks {
486 let chunk = prune_additional_cols(
487 chunk,
488 &[file_path_idx, file_pos_idx],
489 &source_desc.columns,
490 );
491 let (chunk, op) = chunk.into_parts();
493 let (mut columns, visibility) = chunk.into_parts();
494 columns.insert(
495 row_id_idx,
496 Arc::new(
497 SerialArray::from_iter_bitmap(
498 itertools::repeat_n(Serial::from(0), columns[0].len()),
499 Bitmap::zeros(columns[0].len()),
500 )
501 .into(),
502 ),
503 );
504 let chunk = StreamChunk::from_parts(
505 op,
506 DataChunk::from_parts(columns.into(), visibility),
507 );
508
509 yield Message::Chunk(chunk);
510 }
511 }
512 }
513 }
514 }
515 }
516 }
517}
518
519impl<S: StateStore> Execute for IcebergFetchExecutor<S> {
520 fn execute(self: Box<Self>) -> BoxedMessageStream {
521 self.into_stream().boxed()
522 }
523}
524
525impl<S: StateStore> Debug for IcebergFetchExecutor<S> {
526 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
527 if let Some(core) = &self.stream_source_core {
528 f.debug_struct("IcebergFetchExecutor")
529 .field("source_id", &core.source_id)
530 .field("column_ids", &core.column_ids)
531 .finish()
532 } else {
533 f.debug_struct("IcebergFetchExecutor").finish()
534 }
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use itertools::Itertools;
541 use risingwave_common::array::{DataChunk, Op, StreamChunk};
542
543 use super::ChunksWithState;
544
545 #[test]
561 fn test_empty_chunks_with_state_satisfies_into_stream_contract() {
562 let path = "s3://bucket/empty.parquet".to_owned();
563
564 let cws = ChunksWithState {
566 chunks: vec![],
567 data_file_path: path.clone(),
568 last_read_pos: None,
569 };
570
571 let forwarded: Vec<_> = cws.chunks.iter().collect();
573 assert!(
574 forwarded.is_empty(),
575 "empty ChunksWithState must not forward any rows"
576 );
577
578 assert_eq!(
580 cws.data_file_path, path,
581 "data_file_path must match the original task path"
582 );
583 }
584
585 #[test]
587 fn test_non_empty_chunks_with_state() {
588 let chunk = StreamChunk::from_parts(
589 vec![Op::Insert, Op::Insert, Op::Insert],
590 DataChunk::new_dummy(3),
591 );
592 let cws = ChunksWithState {
593 chunks: vec![chunk],
594 data_file_path: "s3://bucket/data.parquet".to_owned(),
595 last_read_pos: None,
596 };
597
598 assert_eq!(cws.chunks.len(), 1);
599 assert_eq!(cws.chunks[0].cardinality(), 3);
600 }
601
602 #[test]
610 fn test_zero_cardinality_chunks_are_excluded() {
611 let path = "s3://bucket/mostly-deleted.parquet".to_owned();
613
614 let mut chunks: Vec<StreamChunk> = vec![];
615
616 let zero_row_chunk = DataChunk::new_dummy(0);
618 if zero_row_chunk.cardinality() == 0 {
619 } else {
621 chunks.push(StreamChunk::from_parts(
622 itertools::repeat_n(Op::Insert, zero_row_chunk.cardinality()).collect_vec(),
623 zero_row_chunk,
624 ));
625 }
626
627 assert!(
629 chunks.is_empty(),
630 "zero-cardinality chunk must not be added to the chunks vec"
631 );
632
633 let cws = ChunksWithState {
635 chunks,
636 data_file_path: path.clone(),
637 last_read_pos: None,
638 };
639
640 assert_eq!(
642 cws.data_file_path, path,
643 "data_file_path must be set even when all chunks are zero-cardinality"
644 );
645 assert!(cws.chunks.is_empty());
647 }
648}