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::metrics::GLOBAL_ICEBERG_SCAN_METRICS;
32use risingwave_connector::source::iceberg::{
33 IcebergScanOpts, 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
68pub(crate) struct ChunksWithState {
73 pub chunks: Vec<StreamChunk>,
75
76 pub data_file_path: String,
78
79 #[expect(dead_code)]
81 pub last_read_pos: Datum,
82}
83
84impl<S: StateStore> IcebergFetchExecutor<S> {
85 pub fn new(
86 actor_ctx: ActorContextRef,
87 stream_source_core: StreamSourceCore<S>,
88 upstream: Executor,
89 rate_limit_rps: Option<u32>,
90 streaming_config: Arc<StreamingConfig>,
91 ) -> Self {
92 Self {
93 actor_ctx,
94 stream_source_core: Some(stream_source_core),
95 upstream: Some(upstream),
96 rate_limit_rps,
97 streaming_config,
98 }
99 }
100
101 #[expect(clippy::too_many_arguments)]
102 async fn replace_with_new_batch_reader<const BIASED: bool>(
103 splits_on_fetch: &mut usize,
104 state_store_handler: &SourceStateTableHandler<S>,
105 column_ids: Vec<ColumnId>,
106 source_ctx: SourceContext,
107 source_desc: SourceDesc,
108 stream: &mut StreamReaderWithPause<BIASED, ChunksWithState>,
109 rate_limit_rps: Option<u32>,
110 streaming_config: Arc<StreamingConfig>,
111 ) -> StreamExecutorResult<()> {
112 let mut batch =
113 Vec::with_capacity(streaming_config.developer.iceberg_fetch_batch_size as usize);
114 let state_table = state_store_handler.state_table();
115 'vnodes: for vnode in state_table.vnodes().iter_vnodes() {
116 let table_iter = state_table
117 .iter_with_vnode(
118 vnode,
119 &(Bound::<OwnedRow>::Unbounded, Bound::<OwnedRow>::Unbounded),
120 PrefetchOptions::prefetch_for_small_range_scan(),
122 )
123 .await?;
124 pin_mut!(table_iter);
125 while let Some(item) = table_iter.next().await {
126 let row = item?;
127 let task = match row.datum_at(1) {
128 Some(ScalarRefImpl::Jsonb(jsonb_ref)) => {
129 PersistedFileScanTask::decode(jsonb_ref)?
130 }
131 _ => unreachable!(),
132 };
133 batch.push(task);
134
135 if batch.len() >= streaming_config.developer.iceberg_fetch_batch_size as usize {
136 break 'vnodes;
137 }
138 }
139 }
140 if batch.is_empty() {
141 stream.replace_data_stream(stream::pending().boxed());
142 } else {
143 *splits_on_fetch += batch.len();
144 let batch_reader = Self::build_batched_stream_reader(
145 column_ids,
146 source_ctx,
147 source_desc,
148 batch,
149 rate_limit_rps,
150 streaming_config,
151 )
152 .map_err(StreamExecutorError::connector_error);
153 stream.replace_data_stream(batch_reader);
154 }
155
156 Ok(())
157 }
158
159 #[try_stream(ok = ChunksWithState, error = StreamExecutorError)]
160 async fn build_batched_stream_reader(
161 _column_ids: Vec<ColumnId>,
162 _source_ctx: SourceContext,
163 source_desc: SourceDesc,
164 batch: Vec<FileScanTask>,
165 _rate_limit_rps: Option<u32>,
166 streaming_config: Arc<StreamingConfig>,
167 ) {
168 let file_path_idx = source_desc
169 .columns
170 .iter()
171 .position(|c| c.name == ICEBERG_FILE_PATH_COLUMN_NAME)
172 .unwrap();
173 let file_pos_idx = source_desc
174 .columns
175 .iter()
176 .position(|c| c.name == ICEBERG_FILE_POS_COLUMN_NAME)
177 .unwrap();
178 let properties = source_desc.source.config.clone();
179 let properties = match properties {
180 risingwave_connector::source::ConnectorProperties::Iceberg(iceberg_properties) => {
181 iceberg_properties
182 }
183 _ => unreachable!(),
184 };
185 let table = properties.load_table().await?;
186 let metrics = Arc::new(GLOBAL_ICEBERG_SCAN_METRICS.clone());
187
188 for task in batch {
189 let task_data_file_path = task.data_file_path.clone();
192 let mut chunks = vec![];
193 #[for_await]
194 for chunk in scan_task_to_chunk_with_deletes(
195 table.clone(),
196 task,
197 IcebergScanOpts {
198 chunk_size: streaming_config.developer.chunk_size,
199 need_seq_num: true, need_file_path_and_pos: true,
201 handle_delete_files: table.metadata().format_version()
205 >= iceberg::spec::FormatVersion::V3,
206 },
207 Some(metrics.clone()),
208 ) {
209 let chunk = chunk?;
210 if chunk.cardinality() == 0 {
216 continue;
217 }
218 chunks.push(StreamChunk::from_parts(
219 itertools::repeat_n(Op::Insert, chunk.cardinality()).collect_vec(),
220 chunk,
221 ));
222 }
223 let (data_file_path, last_read_pos) = if let Some(last_chunk) = chunks.last() {
229 let last_row = last_chunk.row_at(last_chunk.cardinality() - 1).1;
230 let path = last_row
231 .datum_at(file_path_idx)
232 .unwrap()
233 .into_utf8()
234 .to_owned();
235 let pos = last_row.datum_at(file_pos_idx).unwrap().to_owned_datum();
236 (path, pos)
237 } else {
238 (task_data_file_path, None)
241 };
242 yield ChunksWithState {
243 chunks,
244 data_file_path,
245 last_read_pos,
246 };
247 }
248 }
249
250 fn build_source_ctx(
251 &self,
252 source_desc: &SourceDesc,
253 source_id: SourceId,
254 source_name: &str,
255 ) -> SourceContext {
256 SourceContext::new(
257 self.actor_ctx.id,
258 source_id,
259 self.actor_ctx.fragment_id,
260 source_name.to_owned(),
261 source_desc.metrics.clone(),
262 SourceCtrlOpts {
263 chunk_size: limited_chunk_size(self.rate_limit_rps),
264 split_txn: self.rate_limit_rps.is_some(), },
266 source_desc.source.config.clone(),
267 None,
268 )
269 }
270
271 #[try_stream(ok = Message, error = StreamExecutorError)]
272 async fn into_stream(mut self) {
273 let mut upstream = self.upstream.take().unwrap().execute();
274 let barrier = expect_first_barrier(&mut upstream).await?;
275 let first_epoch = barrier.epoch;
276 let is_pause_on_startup = barrier.is_pause_on_startup();
277 yield Message::Barrier(barrier);
278
279 let mut core = self.stream_source_core.take().unwrap();
280 let mut state_store_handler = core.split_state_store;
281
282 let source_desc_builder = core.source_desc_builder.take().unwrap();
284
285 let source_desc = source_desc_builder
286 .build()
287 .map_err(StreamExecutorError::connector_error)?;
288
289 let file_path_idx = source_desc
290 .columns
291 .iter()
292 .position(|c| c.name == ICEBERG_FILE_PATH_COLUMN_NAME)
293 .unwrap();
294 let file_pos_idx = source_desc
295 .columns
296 .iter()
297 .position(|c| c.name == ICEBERG_FILE_POS_COLUMN_NAME)
298 .unwrap();
299 let row_id_idx = source_desc
301 .columns
302 .iter()
303 .position(|c| c.name == ROW_ID_COLUMN_NAME)
304 .unwrap();
305 tracing::trace!(
306 "source_desc.columns: {:#?}, file_path_idx: {}, file_pos_idx: {}, row_id_idx: {}",
307 source_desc.columns,
308 file_path_idx,
309 file_pos_idx,
310 row_id_idx
311 );
312 state_store_handler.init_epoch(first_epoch).await?;
314
315 let iceberg_metrics = &GLOBAL_ICEBERG_SCAN_METRICS;
317 let iceberg_table_name = {
318 match &source_desc.source.config {
319 risingwave_connector::source::ConnectorProperties::Iceberg(props) => {
320 props.table.table_name().to_owned()
321 }
322 _ => unreachable!("IcebergFetchExecutor must be built with Iceberg properties"),
323 }
324 };
325 let source_id_str = core.source_id.to_string();
326 let source_name_str = core.source_name.clone();
327 let metrics_labels = [
328 source_id_str.as_str(),
329 source_name_str.as_str(),
330 iceberg_table_name.as_str(),
331 ];
332
333 let mut splits_on_fetch: usize = 0;
334 let mut stream = StreamReaderWithPause::<true, ChunksWithState>::new(
335 upstream,
336 stream::pending().boxed(),
337 );
338
339 if is_pause_on_startup {
340 stream.pause_stream();
341 }
342
343 Self::replace_with_new_batch_reader(
347 &mut splits_on_fetch,
348 &state_store_handler, core.column_ids.clone(),
350 self.build_source_ctx(&source_desc, core.source_id, &core.source_name),
351 source_desc.clone(),
352 &mut stream,
353 self.rate_limit_rps,
354 self.streaming_config.clone(),
355 )
356 .await?;
357 iceberg_metrics
358 .iceberg_source_inflight_file_count
359 .with_guarded_label_values(&metrics_labels)
360 .set(splits_on_fetch as i64);
361
362 while let Some(msg) = stream.next().await {
363 match msg {
364 Err(e) => {
365 tracing::error!(error = %e.as_report(), "Fetch Error");
366 iceberg_metrics
367 .iceberg_source_scan_errors_total
368 .with_guarded_label_values(&[
369 metrics_labels[0],
370 metrics_labels[1],
371 metrics_labels[2],
372 "fetch_error",
373 ])
374 .inc();
375 splits_on_fetch = 0;
376 iceberg_metrics
377 .iceberg_source_inflight_file_count
378 .with_guarded_label_values(&metrics_labels)
379 .set(0);
380 }
381 Ok(msg) => {
382 match msg {
383 Either::Left(msg) => {
385 match msg {
386 Message::Barrier(barrier) => {
387 let mut need_rebuild_reader = false;
388
389 if let Some(mutation) = barrier.mutation.as_deref() {
390 match mutation {
391 Mutation::Pause => stream.pause_stream(),
392 Mutation::Resume => stream.resume_stream(),
393 Mutation::Throttle(fragment_to_apply) => {
394 if let Some(entry) = fragment_to_apply
395 .get(&self.actor_ctx.fragment_id)
396 && entry.throttle_type() == ThrottleType::Source
397 && entry.rate_limit != self.rate_limit_rps
398 {
399 tracing::debug!(
400 "updating rate limit from {:?} to {:?}",
401 self.rate_limit_rps,
402 entry.rate_limit
403 );
404 self.rate_limit_rps = entry.rate_limit;
405 need_rebuild_reader = true;
406 }
407 }
408 _ => (),
409 }
410 }
411
412 let post_commit = state_store_handler
413 .commit_may_update_vnode_bitmap(barrier.epoch)
414 .await?;
415
416 let update_vnode_bitmap =
417 barrier.as_update_vnode_bitmap(self.actor_ctx.id);
418 yield Message::Barrier(barrier);
420
421 if post_commit
422 .post_yield_barrier(update_vnode_bitmap)
423 .await?
424 .is_some()
425 {
426 splits_on_fetch = 0;
430 }
431
432 if splits_on_fetch == 0 || need_rebuild_reader {
433 Self::replace_with_new_batch_reader(
434 &mut splits_on_fetch,
435 &state_store_handler,
436 core.column_ids.clone(),
437 self.build_source_ctx(
438 &source_desc,
439 core.source_id,
440 &core.source_name,
441 ),
442 source_desc.clone(),
443 &mut stream,
444 self.rate_limit_rps,
445 self.streaming_config.clone(),
446 )
447 .await?;
448 iceberg_metrics
449 .iceberg_source_inflight_file_count
450 .with_guarded_label_values(&metrics_labels)
451 .set(splits_on_fetch as i64);
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 iceberg_metrics
483 .iceberg_source_inflight_file_count
484 .with_guarded_label_values(&metrics_labels)
485 .set(splits_on_fetch as i64);
486 }
487
488 for chunk in &chunks {
489 let chunk = prune_additional_cols(
490 chunk,
491 &[file_path_idx, file_pos_idx],
492 &source_desc.columns,
493 );
494 let (chunk, op) = chunk.into_parts();
496 let (mut columns, visibility) = chunk.into_parts();
497 columns.insert(
498 row_id_idx,
499 Arc::new(
500 SerialArray::from_iter_bitmap(
501 itertools::repeat_n(Serial::from(0), columns[0].len()),
502 Bitmap::zeros(columns[0].len()),
503 )
504 .into(),
505 ),
506 );
507 let chunk = StreamChunk::from_parts(
508 op,
509 DataChunk::from_parts(columns.into(), visibility),
510 );
511
512 yield Message::Chunk(chunk);
513 }
514 }
515 }
516 }
517 }
518 }
519 }
520}
521
522impl<S: StateStore> Execute for IcebergFetchExecutor<S> {
523 fn execute(self: Box<Self>) -> BoxedMessageStream {
524 self.into_stream().boxed()
525 }
526}
527
528impl<S: StateStore> Debug for IcebergFetchExecutor<S> {
529 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
530 if let Some(core) = &self.stream_source_core {
531 f.debug_struct("IcebergFetchExecutor")
532 .field("source_id", &core.source_id)
533 .field("column_ids", &core.column_ids)
534 .finish()
535 } else {
536 f.debug_struct("IcebergFetchExecutor").finish()
537 }
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use itertools::Itertools;
544 use risingwave_common::array::{DataChunk, Op, StreamChunk};
545
546 use super::ChunksWithState;
547
548 #[test]
564 fn test_empty_chunks_with_state_satisfies_into_stream_contract() {
565 let path = "s3://bucket/empty.parquet".to_owned();
566
567 let cws = ChunksWithState {
569 chunks: vec![],
570 data_file_path: path.clone(),
571 last_read_pos: None,
572 };
573
574 let forwarded: Vec<_> = cws.chunks.iter().collect();
576 assert!(
577 forwarded.is_empty(),
578 "empty ChunksWithState must not forward any rows"
579 );
580
581 assert_eq!(
583 cws.data_file_path, path,
584 "data_file_path must match the original task path"
585 );
586 }
587
588 #[test]
590 fn test_non_empty_chunks_with_state() {
591 let chunk = StreamChunk::from_parts(
592 vec![Op::Insert, Op::Insert, Op::Insert],
593 DataChunk::new_dummy(3),
594 );
595 let cws = ChunksWithState {
596 chunks: vec![chunk],
597 data_file_path: "s3://bucket/data.parquet".to_owned(),
598 last_read_pos: None,
599 };
600
601 assert_eq!(cws.chunks.len(), 1);
602 assert_eq!(cws.chunks[0].cardinality(), 3);
603 }
604
605 #[test]
613 fn test_zero_cardinality_chunks_are_excluded() {
614 let path = "s3://bucket/mostly-deleted.parquet".to_owned();
616
617 let mut chunks: Vec<StreamChunk> = vec![];
618
619 let zero_row_chunk = DataChunk::new_dummy(0);
621 if zero_row_chunk.cardinality() == 0 {
622 } else {
624 chunks.push(StreamChunk::from_parts(
625 itertools::repeat_n(Op::Insert, zero_row_chunk.cardinality()).collect_vec(),
626 zero_row_chunk,
627 ));
628 }
629
630 assert!(
632 chunks.is_empty(),
633 "zero-cardinality chunk must not be added to the chunks vec"
634 );
635
636 let cws = ChunksWithState {
638 chunks,
639 data_file_path: path.clone(),
640 last_read_pos: None,
641 };
642
643 assert_eq!(
645 cws.data_file_path, path,
646 "data_file_path must be set even when all chunks are zero-cardinality"
647 );
648 assert!(cws.chunks.is_empty());
650 }
651}