Skip to main content

risingwave_stream/executor/source/batch_source/
batch_opendal_fs_fetch.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 std::collections::VecDeque;
16use std::marker::PhantomData;
17use std::sync::Arc;
18
19use either::Either;
20use futures::TryStreamExt;
21use futures::stream::{self, StreamExt};
22use futures_async_stream::try_stream;
23use risingwave_common::catalog::ColumnId;
24use risingwave_common::id::TableId;
25use risingwave_connector::source::filesystem::OpendalFsSplit;
26use risingwave_connector::source::filesystem::opendal_source::OpendalSource;
27use risingwave_connector::source::reader::desc::SourceDesc;
28use risingwave_connector::source::{
29    BoxStreamingFileSourceChunkStream, SourceContext, SourceCtrlOpts, SplitImpl,
30};
31use thiserror_ext::AsReport;
32
33use crate::common::rate_limit::limited_chunk_size;
34use crate::executor::prelude::*;
35use crate::executor::source::{
36    StreamSourceCore, apply_rate_limit_with_for_streaming_file_source_reader,
37    get_split_offset_col_idx, prune_additional_cols, source_reader_event_to_chunk_stream,
38};
39use crate::executor::stream_reader::StreamReaderWithPause;
40use crate::task::LocalBarrierManager;
41
42const BATCH_SIZE: usize = 1000;
43
44pub struct BatchOpendalFsFetchExecutor<S: StateStore, Src: OpendalSource>
45where
46    SplitImpl: From<OpendalFsSplit<Src>>,
47{
48    actor_ctx: ActorContextRef,
49
50    /// Core component for managing external streaming source state.
51    stream_source_core: Option<StreamSourceCore<S>>,
52
53    /// Upstream list executor that provides file assignments.
54    upstream: Option<Executor>,
55
56    /// Optional rate limit in rows/s to control data ingestion speed.
57    rate_limit_rps: Option<u32>,
58
59    /// Local barrier manager for reporting load finished.
60    barrier_manager: LocalBarrierManager,
61
62    associated_table_id: TableId,
63
64    _marker: PhantomData<Src>,
65}
66
67impl<S: StateStore, Src: OpendalSource> BatchOpendalFsFetchExecutor<S, Src>
68where
69    SplitImpl: From<OpendalFsSplit<Src>>,
70{
71    pub fn new(
72        actor_ctx: ActorContextRef,
73        stream_source_core: StreamSourceCore<S>,
74        upstream: Executor,
75        rate_limit_rps: Option<u32>,
76        barrier_manager: LocalBarrierManager,
77        associated_table_id: Option<TableId>,
78    ) -> Self {
79        assert!(associated_table_id.is_some());
80        Self {
81            actor_ctx,
82            stream_source_core: Some(stream_source_core),
83            upstream: Some(upstream),
84            rate_limit_rps,
85            barrier_manager,
86            associated_table_id: associated_table_id.unwrap(),
87            _marker: PhantomData,
88        }
89    }
90
91    fn build_source_ctx(
92        actor_ctx: &ActorContextRef,
93        source_desc: &SourceDesc,
94        core: &StreamSourceCore<S>,
95        rate_limit_rps: Option<u32>,
96    ) -> SourceContext {
97        SourceContext::new(
98            actor_ctx.id,
99            core.source_id,
100            actor_ctx.fragment_id,
101            core.source_name.clone(),
102            source_desc.metrics.clone(),
103            SourceCtrlOpts {
104                chunk_size: limited_chunk_size(rate_limit_rps),
105                split_txn: rate_limit_rps.is_some(),
106            },
107            source_desc.source.config.clone(),
108            None,
109        )
110    }
111
112    async fn build_single_file_stream_reader(
113        column_ids: Vec<ColumnId>,
114        source_ctx: SourceContext,
115        source_desc: &SourceDesc,
116        split: OpendalFsSplit<Src>,
117        rate_limit_rps: Option<u32>,
118    ) -> StreamExecutorResult<BoxStreamingFileSourceChunkStream> {
119        let (stream, _) = source_desc
120            .source
121            .build_stream(
122                Some(vec![SplitImpl::from(split)]),
123                column_ids,
124                Arc::new(source_ctx),
125                false,
126            )
127            .await
128            .map_err(StreamExecutorError::connector_error)?;
129        let optional_stream: BoxStreamingFileSourceChunkStream =
130            source_reader_event_to_chunk_stream(stream)
131                .boxed()
132                .map(|item| item.map(Some))
133                .chain(stream::once(async { Ok(None) }))
134                .boxed();
135        Ok(
136            apply_rate_limit_with_for_streaming_file_source_reader(optional_stream, rate_limit_rps)
137                .boxed(),
138        )
139    }
140
141    async fn replace_with_new_batch_reader<const BIASED: bool>(
142        files_in_progress: &mut usize,
143        file_queue: &mut VecDeque<OpendalFsSplit<Src>>,
144        stream: &mut StreamReaderWithPause<BIASED, Option<StreamChunk>>,
145        column_ids: Vec<ColumnId>,
146        source_ctx: SourceContext,
147        source_desc: &SourceDesc,
148        rate_limit_rps: Option<u32>,
149    ) -> StreamExecutorResult<()> {
150        let mut batch = Vec::with_capacity(BATCH_SIZE);
151        for _ in 0..BATCH_SIZE {
152            let Some(split) = file_queue.pop_front() else {
153                break;
154            };
155            batch.push(split);
156        }
157
158        if batch.is_empty() {
159            stream.replace_data_stream(stream::pending().boxed());
160        } else {
161            *files_in_progress += batch.len();
162            let mut merged_stream =
163                stream::empty::<StreamExecutorResult<Option<StreamChunk>>>().boxed();
164            for split in batch {
165                let single_file_stream = Self::build_single_file_stream_reader(
166                    column_ids.clone(),
167                    source_ctx.clone(),
168                    source_desc,
169                    split,
170                    rate_limit_rps,
171                )
172                .await?
173                .map_err(StreamExecutorError::connector_error);
174                merged_stream = merged_stream.chain(single_file_stream).boxed();
175            }
176            stream.replace_data_stream(merged_stream);
177        }
178
179        Ok(())
180    }
181
182    #[try_stream(ok = Message, error = StreamExecutorError)]
183    async fn into_stream(mut self) {
184        let mut upstream = self.upstream.take().unwrap().execute();
185        let first_barrier = expect_first_barrier(&mut upstream).await?;
186        let is_pause_on_startup = first_barrier.is_pause_on_startup();
187        yield Message::Barrier(first_barrier);
188
189        let mut core = self.stream_source_core.take().unwrap();
190        let source_desc = core
191            .source_desc_builder
192            .take()
193            .unwrap()
194            .build()
195            .map_err(StreamExecutorError::connector_error)?;
196
197        let (Some(split_idx), Some(offset_idx), _) = get_split_offset_col_idx(&source_desc.columns)
198        else {
199            unreachable!("Partition and offset columns must be set.");
200        };
201
202        let mut files_in_progress = 0;
203        let mut file_queue = VecDeque::new();
204        let mut list_finished = false;
205        let mut is_refreshing = false;
206        let mut stream = StreamReaderWithPause::<true, Option<StreamChunk>>::new(
207            upstream,
208            stream::pending().boxed(),
209        );
210
211        if is_pause_on_startup {
212            stream.pause_stream();
213        }
214
215        while let Some(msg) = stream.next().await {
216            match msg {
217                Err(e) => {
218                    tracing::error!(error = %e.as_report(), "Batch OpenDAL fetch error");
219                    return Err(e);
220                }
221                Ok(msg) => match msg {
222                    Either::Left(msg) => match msg {
223                        Message::Barrier(barrier) => {
224                            if let Some(mutation) = barrier.mutation.as_deref() {
225                                match mutation {
226                                    Mutation::Pause => stream.pause_stream(),
227                                    Mutation::Resume => stream.resume_stream(),
228                                    Mutation::RefreshStart {
229                                        associated_source_id,
230                                        ..
231                                    } if associated_source_id == &core.source_id => {
232                                        tracing::info!(
233                                            ?barrier.epoch,
234                                            actor_id = %self.actor_ctx.id,
235                                            source_id = %core.source_id,
236                                            table_id = %self.associated_table_id,
237                                            queue_len = file_queue.len(),
238                                            files_in_progress,
239                                            "RefreshStart: clearing batch OpenDAL fetch state"
240                                        );
241                                        file_queue.clear();
242                                        files_in_progress = 0;
243                                        list_finished = false;
244                                        is_refreshing = true;
245                                        stream.replace_data_stream(stream::pending().boxed());
246                                    }
247                                    Mutation::ListFinish {
248                                        associated_source_id,
249                                    } if associated_source_id == &core.source_id => {
250                                        tracing::info!(
251                                            ?barrier.epoch,
252                                            actor_id = %self.actor_ctx.id,
253                                            source_id = %core.source_id,
254                                            table_id = %self.associated_table_id,
255                                            "received ListFinish mutation"
256                                        );
257                                        list_finished = true;
258                                    }
259                                    _ => (),
260                                }
261                            }
262
263                            if files_in_progress == 0
264                                && file_queue.is_empty()
265                                && list_finished
266                                && is_refreshing
267                                && barrier.is_checkpoint()
268                            {
269                                tracing::info!(
270                                    ?barrier.epoch,
271                                    actor_id = %self.actor_ctx.id,
272                                    source_id = %core.source_id,
273                                    table_id = %self.associated_table_id,
274                                    "Reporting batch OpenDAL source load finished"
275                                );
276                                self.barrier_manager.report_source_load_finished(
277                                    barrier.epoch,
278                                    self.actor_ctx.id,
279                                    self.associated_table_id,
280                                    core.source_id,
281                                );
282                                list_finished = false;
283                                is_refreshing = false;
284                            }
285
286                            yield Message::Barrier(barrier);
287
288                            if files_in_progress == 0 && !file_queue.is_empty() && is_refreshing {
289                                let source_ctx = Self::build_source_ctx(
290                                    &self.actor_ctx,
291                                    &source_desc,
292                                    &core,
293                                    self.rate_limit_rps,
294                                );
295                                Self::replace_with_new_batch_reader(
296                                    &mut files_in_progress,
297                                    &mut file_queue,
298                                    &mut stream,
299                                    core.column_ids.clone(),
300                                    source_ctx,
301                                    &source_desc,
302                                    self.rate_limit_rps,
303                                )
304                                .await?;
305                            }
306                        }
307                        Message::Chunk(chunk) => {
308                            for row in chunk.data_chunk().rows() {
309                                let filename = row.datum_at(0).unwrap().into_utf8();
310                                let size = row.datum_at(2).unwrap().into_int64();
311
312                                if size > 0 {
313                                    file_queue.push_back(OpendalFsSplit::<Src>::new(
314                                        filename.to_owned(),
315                                        0,
316                                        size as usize,
317                                    ));
318                                }
319                            }
320
321                            tracing::debug!(
322                                actor_id = %self.actor_ctx.id,
323                                source_id = %core.source_id,
324                                queue_len = file_queue.len(),
325                                "Added OpenDAL file assignments to batch fetch queue"
326                            );
327                        }
328                        Message::Watermark(_) => unreachable!(),
329                    },
330                    Either::Right(optional_chunk) => match optional_chunk {
331                        Some(chunk) => {
332                            let chunk = prune_additional_cols(
333                                &chunk,
334                                &[split_idx, offset_idx],
335                                &source_desc.columns,
336                            );
337                            yield Message::Chunk(chunk);
338                        }
339                        None => {
340                            files_in_progress = files_in_progress.saturating_sub(1);
341                        }
342                    },
343                },
344            }
345        }
346    }
347}
348
349impl<S: StateStore, Src: OpendalSource> Execute for BatchOpendalFsFetchExecutor<S, Src>
350where
351    SplitImpl: From<OpendalFsSplit<Src>>,
352{
353    fn execute(self: Box<Self>) -> BoxedMessageStream {
354        self.into_stream().boxed()
355    }
356}
357
358impl<S: StateStore, Src: OpendalSource> Debug for BatchOpendalFsFetchExecutor<S, Src>
359where
360    SplitImpl: From<OpendalFsSplit<Src>>,
361{
362    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
363        if let Some(core) = &self.stream_source_core {
364            f.debug_struct("BatchOpendalFsFetchExecutor")
365                .field("source_id", &core.source_id)
366                .field("column_ids", &core.column_ids)
367                .finish()
368        } else {
369            f.debug_struct("BatchOpendalFsFetchExecutor").finish()
370        }
371    }
372}