Skip to main content

risingwave_stream/executor/source/batch_source/
batch_posix_fs_fetch.rs

1// Copyright 2025 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::io::BufRead;
17use std::path::Path;
18
19use either::Either;
20use futures::TryStreamExt;
21use futures::stream::{self, StreamExt};
22use futures_async_stream::try_stream;
23use risingwave_common::id::TableId;
24use risingwave_common::types::{JsonbVal, ScalarRef};
25use risingwave_common_rate_limit::RateLimiter;
26use risingwave_connector::parser::{ByteStreamSourceParserImpl, CommonParserConfig, ParserConfig};
27use risingwave_connector::source::filesystem::OpendalFsSplit;
28use risingwave_connector::source::filesystem::opendal_source::OpendalPosixFs;
29use risingwave_connector::source::{
30    ConnectorProperties, SourceChunkStream, SourceContext, SourceCtrlOpts, SourceMessage,
31    SourceMessageEvent, SourceMeta, SourceReaderEvent, SplitMetaData,
32};
33use risingwave_pb::common::ThrottleType;
34use thiserror_ext::AsReport;
35use tokio::fs;
36
37use crate::common::rate_limit::limited_chunk_size;
38use crate::executor::prelude::*;
39use crate::executor::source::{StreamSourceCore, get_split_offset_col_idx, prune_additional_cols};
40use crate::executor::stream_reader::StreamReaderWithPause;
41use crate::task::LocalBarrierManager;
42
43/// Maximum number of files to process in a single batch
44const BATCH_SIZE: usize = 1000;
45
46fn into_data_chunk_stream(
47    stream: impl futures::Stream<Item = risingwave_connector::error::ConnectorResult<SourceReaderEvent>>
48    + Send
49    + 'static,
50) -> impl SourceChunkStream {
51    stream
52        .try_filter_map(|event| async move {
53            Ok(match event {
54                SourceReaderEvent::DataChunk(chunk) => Some(chunk),
55                SourceReaderEvent::SplitProgress(_) => None,
56            })
57        })
58        .boxed()
59}
60
61/// Executor for fetching and processing files in batch mode for refreshable tables.
62///
63/// This executor receives file assignments from an upstream list executor,
64/// reads the files, parses their contents, and emits stream chunks.
65///
66/// Key characteristics:
67/// - Uses **ephemeral in-memory state** (no persistent state table)
68/// - State is cleared on recovery and `RefreshStart` mutations
69/// - Suitable for refreshable materialized views
70pub struct BatchPosixFsFetchExecutor<S: StateStore> {
71    actor_ctx: ActorContextRef,
72
73    /// Core component for managing external streaming source state
74    stream_source_core: Option<StreamSourceCore<S>>,
75
76    /// Upstream list executor that provides the list of files to read
77    upstream: Option<Executor>,
78
79    /// Optional rate limit in rows/s to control data ingestion speed
80    rate_limit_rps: Option<u32>,
81
82    /// Shared with the running reader, so a `Throttle` mutation applies to the file being read.
83    rate_limiter: Arc<RateLimiter>,
84
85    /// Local barrier manager for reporting load finished
86    barrier_manager: LocalBarrierManager,
87
88    /// In-memory queue of file assignments to process (`file_path`, `split_json`).
89    /// This is ephemeral and cleared on recovery and `RefreshStart` mutations.
90    file_queue: VecDeque<(String, JsonbVal)>,
91
92    /// Associated table ID for reporting load finished
93    associated_table_id: TableId,
94}
95
96/// A chunk read from a file, or `None` to mark that a file has been fully read.
97type FileData = Option<StreamChunk>;
98
99impl<S: StateStore> BatchPosixFsFetchExecutor<S> {
100    pub fn new(
101        actor_ctx: ActorContextRef,
102        stream_source_core: StreamSourceCore<S>,
103        upstream: Executor,
104        rate_limit_rps: Option<u32>,
105        barrier_manager: LocalBarrierManager,
106        associated_table_id: Option<TableId>,
107    ) -> Self {
108        assert!(associated_table_id.is_some());
109        Self {
110            actor_ctx,
111            stream_source_core: Some(stream_source_core),
112            upstream: Some(upstream),
113            rate_limit_rps,
114            rate_limiter: Arc::new(RateLimiter::new(rate_limit_rps.into())),
115            barrier_manager,
116            file_queue: VecDeque::new(),
117            associated_table_id: associated_table_id.unwrap(),
118        }
119    }
120
121    /// Pop files from the in-memory queue and create a batch reader for them.
122    /// Processes up to `BATCH_SIZE` files in parallel.
123    fn replace_with_new_batch_reader<const BIASED: bool>(
124        files_in_progress: &mut usize,
125        file_queue: &mut VecDeque<(String, JsonbVal)>,
126        stream: &mut StreamReaderWithPause<BIASED, FileData>,
127        properties: ConnectorProperties,
128        parser_config: ParserConfig,
129        source_ctx: Arc<SourceContext>,
130        rate_limiter: Arc<RateLimiter>,
131    ) -> StreamExecutorResult<()> {
132        // Pop up to BATCH_SIZE files from the queue to process
133        let mut batch = Vec::with_capacity(BATCH_SIZE);
134
135        for _ in 0..BATCH_SIZE {
136            if let Some((_file_path, split_json)) = file_queue.pop_front() {
137                let split = OpendalFsSplit::<OpendalPosixFs>::restore_from_json(split_json)?;
138                batch.push(split);
139            } else {
140                break;
141            }
142        }
143
144        if batch.is_empty() {
145            // No files to process, set stream to pending
146            stream.replace_data_stream(stream::pending().boxed());
147        } else {
148            *files_in_progress += batch.len();
149            let batch_reader = Self::build_batched_stream_reader(
150                batch,
151                properties,
152                parser_config,
153                source_ctx,
154                rate_limiter,
155            );
156            stream.replace_data_stream(batch_reader.boxed());
157        }
158
159        Ok(())
160    }
161
162    /// Build a stream reader that reads multiple files in sequence
163    #[try_stream(ok = FileData, error = StreamExecutorError)]
164    async fn build_batched_stream_reader(
165        batch: Vec<OpendalFsSplit<OpendalPosixFs>>,
166        properties: ConnectorProperties,
167        parser_config: ParserConfig,
168        source_ctx: Arc<SourceContext>,
169        rate_limiter: Arc<RateLimiter>,
170    ) {
171        let ConnectorProperties::BatchPosixFs(batch_posix_fs_properties) = properties else {
172            unreachable!()
173        };
174
175        let root_path = batch_posix_fs_properties.root.clone();
176
177        for split in batch {
178            let file_path = split.name.clone();
179            let full_path = Path::new(&root_path).join(&file_path);
180
181            // Read the entire file
182            let content = match fs::read(&full_path).await {
183                Ok(content) => content,
184                Err(e) => {
185                    tracing::error!(
186                        error = %e.as_report(),
187                        file_path = %full_path.display(),
188                        "Failed to read file"
189                    );
190                    continue;
191                }
192            };
193
194            if content.is_empty() {
195                // Empty file, skip it
196                yield None;
197                continue;
198            }
199
200            // Process the file line by line
201            for line in content.lines() {
202                let line =
203                    line.map_err(|e| StreamExecutorError::connector_error(anyhow::Error::from(e)))?;
204
205                let message = SourceMessage {
206                    key: None,
207                    payload: Some(line.as_bytes().to_vec()),
208                    offset: "0".to_owned(),
209                    split_id: split.id(),
210                    meta: SourceMeta::Empty,
211                };
212
213                // TODO(tab): avoid rebuilding ByteStreamSourceParserImpl for each file
214                // Parser is rebuilt per line because `parse_stream_with_events` consumes it.
215                let parser =
216                    ByteStreamSourceParserImpl::create(parser_config.clone(), source_ctx.clone())
217                        .await?;
218
219                let chunk_stream = into_data_chunk_stream(parser.parse_stream_with_events(
220                    Box::pin(futures::stream::once(async {
221                        Ok(SourceMessageEvent::Data(vec![message]))
222                    })),
223                ));
224
225                #[for_await]
226                for chunk in chunk_stream {
227                    let chunk = chunk?;
228                    rate_limiter.wait(chunk.rate_limit_permits()).await;
229                    yield Some(chunk);
230                }
231            }
232
233            tracing::debug!(file_path, "Processed file");
234            yield None;
235        }
236    }
237
238    #[try_stream(ok = Message, error = StreamExecutorError)]
239    async fn into_stream(mut self) {
240        let mut upstream = self.upstream.take().unwrap().execute();
241        let barrier = expect_first_barrier(&mut upstream).await?;
242        let is_pause_on_startup = barrier.is_pause_on_startup();
243        yield Message::Barrier(barrier);
244
245        let mut core = self.stream_source_core.take().unwrap();
246
247        // Build source description from the builder.
248        let source_desc_builder = core.source_desc_builder.take().unwrap();
249
250        let source_desc = source_desc_builder
251            .build()
252            .map_err(StreamExecutorError::connector_error)?;
253        let (Some(split_idx), Some(offset_idx), _) = get_split_offset_col_idx(&source_desc.columns)
254        else {
255            unreachable!("Partition and offset columns must be set.");
256        };
257
258        let properties = source_desc.source.config.clone();
259        let parser_config = ParserConfig {
260            common: CommonParserConfig {
261                rw_columns: source_desc.columns.clone(),
262            },
263            specific: source_desc.source.parser_config.clone(),
264        };
265
266        let mut files_in_progress: usize = 0;
267        let mut stream =
268            StreamReaderWithPause::<true, FileData>::new(upstream, stream::pending().boxed());
269
270        if is_pause_on_startup {
271            stream.pause_stream();
272        }
273
274        // For refreshable tables, always start fresh on recovery - no state restoration
275        // File queue is empty by default (no restoration from persistent state)
276
277        let mut list_finished = false;
278        let mut is_refreshing = false;
279        let mut file_queue = self.file_queue;
280
281        // Extract fields we'll need later
282        let actor_ctx = self.actor_ctx.clone();
283        let barrier_manager = self.barrier_manager.clone();
284        let rate_limit_rps = &mut self.rate_limit_rps;
285        let rate_limiter = self.rate_limiter.clone();
286
287        let make_source_ctx = |rate_limit_rps: Option<u32>| {
288            Arc::new(SourceContext::new(
289                actor_ctx.id,
290                core.source_id,
291                actor_ctx.fragment_id,
292                core.source_name.clone(),
293                source_desc.metrics.clone(),
294                SourceCtrlOpts {
295                    chunk_size: limited_chunk_size(rate_limit_rps),
296                    split_txn: rate_limit_rps.is_some(),
297                },
298                source_desc.source.config.clone(),
299                None,
300            ))
301        };
302        let mut source_ctx = make_source_ctx(*rate_limit_rps);
303
304        while let Some(msg) = stream.next().await {
305            match msg {
306                Err(e) => {
307                    tracing::error!(error = %e.as_report(), "Fetch Error");
308                    files_in_progress = 0;
309                }
310                Ok(msg) => match msg {
311                    // Barrier messages from upstream
312                    Either::Left(msg) => match msg {
313                        Message::Barrier(barrier) => {
314                            let need_rebuild_reader = false;
315
316                            if let Some(mutation) = barrier.mutation.as_deref() {
317                                match mutation {
318                                    Mutation::Throttle(fragment_to_apply) => {
319                                        if let Some(entry) =
320                                            fragment_to_apply.get(&actor_ctx.fragment_id)
321                                            && entry.throttle_type() == ThrottleType::Source
322                                            && entry.rate_limit != *rate_limit_rps
323                                        {
324                                            tracing::info!(
325                                                "updating rate limit from {:?} to {:?}",
326                                                *rate_limit_rps,
327                                                entry.rate_limit
328                                            );
329                                            *rate_limit_rps = entry.rate_limit;
330                                            rate_limiter.update(entry.rate_limit.into());
331                                            source_ctx = make_source_ctx(*rate_limit_rps);
332                                        }
333                                    }
334                                    Mutation::Pause => stream.pause_stream(),
335                                    Mutation::Resume => stream.resume_stream(),
336                                    Mutation::RefreshStart {
337                                        associated_source_id,
338                                        ..
339                                    } if associated_source_id.as_raw_id()
340                                        == core.source_id.as_raw_id() =>
341                                    {
342                                        tracing::info!(
343                                            ?barrier.epoch,
344                                            actor_id = %actor_ctx.id,
345                                            source_id = %core.source_id,
346                                            queue_len = file_queue.len(),
347                                            files_in_progress,
348                                            "RefreshStart: clearing state and aborting workload"
349                                        );
350
351                                        // Clear all in-memory state
352                                        file_queue.clear();
353                                        files_in_progress = 0;
354                                        list_finished = false;
355                                        is_refreshing = true;
356
357                                        // Abort current file reader
358                                        stream.replace_data_stream(stream::pending().boxed());
359                                    }
360                                    Mutation::ListFinish {
361                                        associated_source_id,
362                                    } if associated_source_id.as_raw_id()
363                                        == core.source_id.as_raw_id() =>
364                                    {
365                                        // ListFinish is for our source
366                                        tracing::info!(
367                                            ?barrier.epoch,
368                                            actor_id = %actor_ctx.id,
369                                            source_id = %core.source_id,
370                                            "received ListFinish mutation"
371                                        );
372                                        list_finished = true;
373                                    }
374                                    _ => (),
375                                }
376                            }
377
378                            let epoch = barrier.epoch;
379
380                            // Report load finished BEFORE yielding barrier when:
381                            // 1. All files have been processed (files_in_progress == 0 and file_queue is empty)
382                            // 2. ListFinish mutation has been received
383                            //
384                            // IMPORTANT: Must report BEFORE yield to ensure epoch is still in inflight_barriers.
385                            // If we yield first, the barrier worker may collect the barrier and remove the epoch
386                            // from inflight_barriers, causing the report to be ignored with a warning.
387                            if files_in_progress == 0
388                                && file_queue.is_empty()
389                                && list_finished
390                                && is_refreshing
391                                && barrier.is_checkpoint()
392                            {
393                                tracing::info!(
394                                    ?epoch,
395                                    actor_id = %actor_ctx.id,
396                                    source_id = %core.source_id,
397                                    "Reporting source load finished"
398                                );
399                                barrier_manager.report_source_load_finished(
400                                    epoch,
401                                    actor_ctx.id,
402                                    self.associated_table_id,
403                                    core.source_id,
404                                );
405                                // Reset the flag to avoid duplicate reports
406                                list_finished = false;
407                                is_refreshing = false;
408                            }
409
410                            // Propagate the barrier AFTER reporting progress.
411                            yield Message::Barrier(barrier);
412
413                            // Rebuild reader when all current files are processed
414                            if (files_in_progress == 0 || need_rebuild_reader)
415                                && *rate_limit_rps != Some(0)
416                            {
417                                Self::replace_with_new_batch_reader(
418                                    &mut files_in_progress,
419                                    &mut file_queue,
420                                    &mut stream,
421                                    properties.clone(),
422                                    parser_config.clone(),
423                                    source_ctx.clone(),
424                                    rate_limiter.clone(),
425                                )?;
426                            }
427                        }
428                        // Receiving file assignments from upstream list executor,
429                        // store into in-memory queue (no persistent state).
430                        Message::Chunk(chunk) => {
431                            for row in chunk.data_chunk().rows() {
432                                let file_name = row.datum_at(0).unwrap().into_utf8().to_owned();
433                                let split = row.datum_at(1).unwrap().into_jsonb().to_owned_scalar();
434                                file_queue.push_back((file_name, split));
435                            }
436
437                            tracing::debug!(
438                                actor_id = %actor_ctx.id,
439                                queue_len = file_queue.len(),
440                                "Added file assignments to queue"
441                            );
442                        }
443                        Message::Watermark(_) => unreachable!(),
444                    },
445                    // Data from file reader
446                    Either::Right(Some(chunk)) => {
447                        let chunk = prune_additional_cols(
448                            &chunk,
449                            &[split_idx, offset_idx],
450                            &source_desc.columns,
451                        );
452                        yield Message::Chunk(chunk);
453                    }
454                    Either::Right(None) => {
455                        files_in_progress -= 1;
456                    }
457                },
458            }
459        }
460    }
461}
462
463impl<S: StateStore> Execute for BatchPosixFsFetchExecutor<S> {
464    fn execute(self: Box<Self>) -> BoxedMessageStream {
465        self.into_stream().boxed()
466    }
467}
468
469impl<S: StateStore> Debug for BatchPosixFsFetchExecutor<S> {
470    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
471        if let Some(core) = &self.stream_source_core {
472            f.debug_struct("BatchPosixFsFetchExecutor")
473                .field("source_id", &core.source_id)
474                .field("column_ids", &core.column_ids)
475                .finish()
476        } else {
477            f.debug_struct("BatchPosixFsFetchExecutor").finish()
478        }
479    }
480}