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