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::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
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
68/// Fetched data from 1 [`FileScanTask`], along with states for checkpointing.
69///
70/// Currently 1 `FileScanTask` -> 1 `ChunksWithState`.
71/// Later after we support reading part of a file, we will support 1 `FileScanTask` -> n `ChunksWithState`.
72pub(crate) struct ChunksWithState {
73    /// The actual data chunks read from the file
74    pub chunks: Vec<StreamChunk>,
75
76    /// Path to the data file, used for checkpointing and error reporting.
77    pub data_file_path: String,
78
79    /// The last read position in the file, used for checkpointing.
80    #[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                    // This usage is similar with `backfill`. So we only need to fetch a large data rather than establish a connection for a whole object.
121                    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            // Capture the file path upfront from the task so we can use it even when the
190            // scan produces no chunks (empty data file or fully equality-deleted file).
191            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, /* Although this column is unnecessary, we still keep it for potential usage in the future */
200                    need_file_path_and_pos: true,
201                    // Iceberg V2 position/equality deletes are exposed as separate delete-file
202                    // tasks for table-engine reads. V3 deletion vectors should be applied by
203                    // iceberg-rs while scanning the data file.
204                    handle_delete_files: table.metadata().format_version()
205                        >= iceberg::spec::FormatVersion::V3,
206                },
207                Some(metrics.clone()),
208            ) {
209                let chunk = chunk?;
210                // Skip zero-cardinality chunks: a RecordBatch with 0 visible rows after
211                // predicate/delete filtering would cause `cardinality() - 1` to underflow
212                // below when we extract the last-row metadata. Skipping here is safe
213                // because the existing `task_data_file_path` fallback already covers
214                // the case where no readable rows are produced.
215                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            // We yield once for each file now, because iceberg-rs doesn't support read part of a file now.
224            // We must always yield — even for an empty task — so that `into_stream` can
225            // decrement `splits_on_fetch` and delete the file assignment from the state
226            // table.  Skipping the yield (e.g. with `continue`) would leave the file
227            // stuck in the state table and prevent subsequent batches from progressing.
228            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                // No rows were produced: fall back to the task's own path so the
239                // consumer can still remove the state entry for this file.
240                (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(), // when rate limiting, we may split txn
265            },
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        // Build source description from the builder.
283        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        // TODO: currently we generate row_id here. If for risingwave iceberg table engine, maybe we can use _risingwave_iceberg_row_id instead.
300        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        // Initialize state table.
313        state_store_handler.init_epoch(first_epoch).await?;
314
315        // Extract table name from iceberg properties for metrics labeling.
316        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        // If it is a recovery startup,
344        // there can be file assignments in the state table.
345        // Hence we try building a reader first.
346        Self::replace_with_new_batch_reader(
347            &mut splits_on_fetch,
348            &state_store_handler, // move into the function
349            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                        // This branch will be preferred.
384                        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                                    // Propagate the barrier.
419                                    yield Message::Barrier(barrier);
420
421                                    if post_commit
422                                        .post_yield_barrier(update_vnode_bitmap)
423                                        .await?
424                                        .is_some()
425                                    {
426                                        // Vnode bitmap update changes which file assignments this executor
427                                        // should read. Rebuild the reader to avoid reading splits that no
428                                        // longer belong to this actor (e.g., during scale-out).
429                                        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                                // 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                                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                                // pad row_id
495                                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    /// Verifies the `ChunksWithState` contract for the empty-task case.
549    ///
550    /// When a `FileScanTask` produces no rows (e.g. an empty data file or an Iceberg file
551    /// fully covered by equality-delete files), `build_batched_stream_reader` now yields a
552    /// `ChunksWithState` with an empty `chunks` vec and the `data_file_path` taken directly
553    /// from the task — rather than skipping the yield entirely.
554    ///
555    /// Skipping the yield would prevent `into_stream` from calling
556    /// `state_store_handler.delete(&data_file_path)` and decrementing `splits_on_fetch`,
557    /// leaving the file assignment stuck in the state table.
558    ///
559    /// This test verifies the two properties that `into_stream` depends on:
560    /// 1. An empty `ChunksWithState` forwards no data downstream (the chunk loop is a
561    ///    no-op), so no spurious rows appear.
562    /// 2. `data_file_path` is populated so the state entry can be cleaned up.
563    #[test]
564    fn test_empty_chunks_with_state_satisfies_into_stream_contract() {
565        let path = "s3://bucket/empty.parquet".to_owned();
566
567        // Simulate what build_batched_stream_reader yields for an empty task.
568        let cws = ChunksWithState {
569            chunks: vec![],
570            data_file_path: path.clone(),
571            last_read_pos: None,
572        };
573
574        // Property 1: no data is forwarded downstream.
575        let forwarded: Vec<_> = cws.chunks.iter().collect();
576        assert!(
577            forwarded.is_empty(),
578            "empty ChunksWithState must not forward any rows"
579        );
580
581        // Property 2: data_file_path is set so the state entry can be deleted.
582        assert_eq!(
583            cws.data_file_path, path,
584            "data_file_path must match the original task path"
585        );
586    }
587
588    /// Verifies that a non-empty `ChunksWithState` carries its chunks unmodified.
589    #[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    /// Verifies that zero-cardinality chunks are excluded from the `chunks` vec.
606    ///
607    /// `scan_task_to_chunk_with_deletes` can emit zero-row `RecordBatch`es after
608    /// predicate or equality-delete filtering. If such a chunk were pushed into `chunks`
609    /// and then chosen as `chunks.last()`, the subsequent `cardinality() - 1` call would
610    /// underflow on `usize` and panic. The fix is to skip those chunks before pushing,
611    /// relying on the `task_data_file_path` fallback to cover the all-empty case.
612    #[test]
613    fn test_zero_cardinality_chunks_are_excluded() {
614        // Simulate what build_batched_stream_reader does when filtering zero-row chunks.
615        let path = "s3://bucket/mostly-deleted.parquet".to_owned();
616
617        let mut chunks: Vec<StreamChunk> = vec![];
618
619        // A zero-cardinality chunk (e.g. from a fully-deleted RecordBatch).
620        let zero_row_chunk = DataChunk::new_dummy(0);
621        if zero_row_chunk.cardinality() == 0 {
622            // skipped — no push
623        } 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        // After the loop, chunks is empty: the fallback path kicks in.
631        assert!(
632            chunks.is_empty(),
633            "zero-cardinality chunk must not be added to the chunks vec"
634        );
635
636        // Simulate the fallback: data_file_path comes from the task.
637        let cws = ChunksWithState {
638            chunks,
639            data_file_path: path.clone(),
640            last_read_pos: None,
641        };
642
643        // State cleanup can still run.
644        assert_eq!(
645            cws.data_file_path, path,
646            "data_file_path must be set even when all chunks are zero-cardinality"
647        );
648        // No spurious rows forwarded downstream.
649        assert!(cws.chunks.is_empty());
650    }
651}