Skip to main content

risingwave_stream/executor/source/
iceberg_fetch_executor.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::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
46/// An executor that fetches data from Iceberg tables.
47///
48/// This executor works with an upstream list executor that provides the list of files to read.
49/// It reads data from Iceberg files in batches, converts them to stream chunks, and passes them
50/// downstream.
51pub struct IcebergFetchExecutor<S: StateStore> {
52    actor_ctx: ActorContextRef,
53
54    /// Core component for managing external streaming source state
55    stream_source_core: Option<StreamSourceCore<S>>,
56
57    /// Upstream list executor that provides the list of files to read.
58    /// This executor is responsible for discovering new files and changes in the Iceberg table.
59    upstream: Option<Executor>,
60
61    /// Optional rate limit in rows/s to control data ingestion speed
62    rate_limit_rps: Option<u32>,
63
64    /// Configuration for streaming operations, including Iceberg-specific settings
65    streaming_config: Arc<StreamingConfig>,
66
67    scan_metrics: Option<IcebergScanMetricsLabels>,
68    file_scan_metrics: Option<IcebergFileScanMetrics>,
69}
70
71/// Fetched data from 1 [`FileScanTask`], along with states for checkpointing.
72///
73/// Currently 1 `FileScanTask` -> 1 `ChunksWithState`.
74/// Later after we support reading part of a file, we will support 1 `FileScanTask` -> n `ChunksWithState`.
75pub(crate) struct ChunksWithState {
76    /// The actual data chunks read from the file
77    pub chunks: Vec<StreamChunk>,
78
79    /// Path to the data file, used for checkpointing and error reporting.
80    pub data_file_path: String,
81
82    /// The last read position in the file, used for checkpointing.
83    #[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                    // This usage is similar with `backfill`. So we only need to fetch a large data rather than establish a connection for a whole object.
127                    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            // Capture the file path upfront from the task so we can use it even when the
196            // scan produces no chunks (empty data file or fully equality-deleted file).
197            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, /* Although this column is unnecessary, we still keep it for potential usage in the future */
206                    need_file_path_and_pos: true,
207                    // Iceberg V2 position/equality deletes are exposed as separate delete-file
208                    // tasks for table-engine reads. V3 deletion vectors should be applied by
209                    // iceberg-rs while scanning the data file.
210                    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                // Skip zero-cardinality chunks: a RecordBatch with 0 visible rows after
217                // predicate/delete filtering would cause `cardinality() - 1` to underflow
218                // below when we extract the last-row metadata. Skipping here is safe
219                // because the existing `task_data_file_path` fallback already covers
220                // the case where no readable rows are produced.
221                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            // We yield once for each file now, because iceberg-rs doesn't support read part of a file now.
230            // We must always yield — even for an empty task — so that `into_stream` can
231            // decrement `splits_on_fetch` and delete the file assignment from the state
232            // table.  Skipping the yield (e.g. with `continue`) would leave the file
233            // stuck in the state table and prevent subsequent batches from progressing.
234            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                // No rows were produced: fall back to the task's own path so the
245                // consumer can still remove the state entry for this file.
246                (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(), // when rate limiting, we may split txn
271            },
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        // Build source description from the builder.
289        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        // TODO: currently we generate row_id here. If for risingwave iceberg table engine, maybe we can use _risingwave_iceberg_row_id instead.
306        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        // Initialize state table.
319        state_store_handler.init_epoch(first_epoch).await?;
320
321        // Extract table name from iceberg properties for metrics labeling.
322        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        // If it is a recovery startup,
359        // there can be file assignments in the state table.
360        // Hence we try building a reader first.
361        Self::replace_with_new_batch_reader(
362            &mut splits_on_fetch,
363            &state_store_handler, // move into the function
364            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                        // This branch will be preferred.
386                        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                                    // Propagate the barrier.
421                                    yield Message::Barrier(barrier);
422
423                                    if post_commit
424                                        .post_yield_barrier(update_vnode_bitmap)
425                                        .await?
426                                        .is_some()
427                                    {
428                                        // Vnode bitmap update changes which file assignments this executor
429                                        // should read. Rebuild the reader to avoid reading splits that no
430                                        // longer belong to this actor (e.g., during scale-out).
431                                        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                                // Receiving file assignments from upstream list executor,
455                                // store into state table.
456                                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                        // StreamChunk from FsSourceReader, and the reader reads only one file.
473                        Either::Right(ChunksWithState {
474                            chunks,
475                            data_file_path,
476                            last_read_pos: _,
477                        }) => {
478                            // TODO: support persist progress after supporting reading part of a file.
479                            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                                // pad row_id
492                                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    /// Verifies the `ChunksWithState` contract for the empty-task case.
546    ///
547    /// When a `FileScanTask` produces no rows (e.g. an empty data file or an Iceberg file
548    /// fully covered by equality-delete files), `build_batched_stream_reader` now yields a
549    /// `ChunksWithState` with an empty `chunks` vec and the `data_file_path` taken directly
550    /// from the task — rather than skipping the yield entirely.
551    ///
552    /// Skipping the yield would prevent `into_stream` from calling
553    /// `state_store_handler.delete(&data_file_path)` and decrementing `splits_on_fetch`,
554    /// leaving the file assignment stuck in the state table.
555    ///
556    /// This test verifies the two properties that `into_stream` depends on:
557    /// 1. An empty `ChunksWithState` forwards no data downstream (the chunk loop is a
558    ///    no-op), so no spurious rows appear.
559    /// 2. `data_file_path` is populated so the state entry can be cleaned up.
560    #[test]
561    fn test_empty_chunks_with_state_satisfies_into_stream_contract() {
562        let path = "s3://bucket/empty.parquet".to_owned();
563
564        // Simulate what build_batched_stream_reader yields for an empty task.
565        let cws = ChunksWithState {
566            chunks: vec![],
567            data_file_path: path.clone(),
568            last_read_pos: None,
569        };
570
571        // Property 1: no data is forwarded downstream.
572        let forwarded: Vec<_> = cws.chunks.iter().collect();
573        assert!(
574            forwarded.is_empty(),
575            "empty ChunksWithState must not forward any rows"
576        );
577
578        // Property 2: data_file_path is set so the state entry can be deleted.
579        assert_eq!(
580            cws.data_file_path, path,
581            "data_file_path must match the original task path"
582        );
583    }
584
585    /// Verifies that a non-empty `ChunksWithState` carries its chunks unmodified.
586    #[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    /// Verifies that zero-cardinality chunks are excluded from the `chunks` vec.
603    ///
604    /// `scan_task_to_chunk_with_deletes` can emit zero-row `RecordBatch`es after
605    /// predicate or equality-delete filtering. If such a chunk were pushed into `chunks`
606    /// and then chosen as `chunks.last()`, the subsequent `cardinality() - 1` call would
607    /// underflow on `usize` and panic. The fix is to skip those chunks before pushing,
608    /// relying on the `task_data_file_path` fallback to cover the all-empty case.
609    #[test]
610    fn test_zero_cardinality_chunks_are_excluded() {
611        // Simulate what build_batched_stream_reader does when filtering zero-row chunks.
612        let path = "s3://bucket/mostly-deleted.parquet".to_owned();
613
614        let mut chunks: Vec<StreamChunk> = vec![];
615
616        // A zero-cardinality chunk (e.g. from a fully-deleted RecordBatch).
617        let zero_row_chunk = DataChunk::new_dummy(0);
618        if zero_row_chunk.cardinality() == 0 {
619            // skipped — no push
620        } 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        // After the loop, chunks is empty: the fallback path kicks in.
628        assert!(
629            chunks.is_empty(),
630            "zero-cardinality chunk must not be added to the chunks vec"
631        );
632
633        // Simulate the fallback: data_file_path comes from the task.
634        let cws = ChunksWithState {
635            chunks,
636            data_file_path: path.clone(),
637            last_read_pos: None,
638        };
639
640        // State cleanup can still run.
641        assert_eq!(
642            cws.data_file_path, path,
643            "data_file_path must be set even when all chunks are zero-cardinality"
644        );
645        // No spurious rows forwarded downstream.
646        assert!(cws.chunks.is_empty());
647    }
648}