Skip to main content

risingwave_frontend/stream_fragmenter/
mod.rs

1// Copyright 2022 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
15mod graph;
16use anyhow::Context;
17use graph::*;
18use risingwave_common::util::recursive::{self, Recurse as _};
19use risingwave_connector::WithPropertiesExt;
20use risingwave_pb::stream_plan::stream_node::NodeBody;
21mod parallelism;
22mod rewrite;
23
24use std::collections::{HashMap, HashSet};
25use std::ops::Deref;
26use std::rc::Rc;
27
28use educe::Educe;
29use risingwave_common::catalog::{FragmentTypeFlag, TableId};
30use risingwave_common::session_config::SessionConfig;
31use risingwave_common::session_config::parallelism::ConfigParallelism;
32use risingwave_common::system_param::AdaptiveParallelismStrategy;
33use risingwave_common::types::DataType;
34use risingwave_common::util::stream_graph_visitor::visit_stream_node_internal_tables;
35use risingwave_connector::source::cdc::CdcScanOptions;
36use risingwave_pb::id::{LocalOperatorId, StreamNodeLocalOperatorId};
37use risingwave_pb::plan_common::JoinType;
38use risingwave_pb::stream_plan::{
39    BackfillOrder, DispatchStrategy, DispatcherType, ExchangeNode, NoOpNode,
40    PbDispatchOutputMapping, StreamContext, StreamFragmentGraph as StreamFragmentGraphProto,
41    StreamNode, StreamScanType,
42};
43
44use self::rewrite::build_delta_join_without_arrange;
45use crate::catalog::FragmentId;
46use crate::error::ErrorCode::NotSupported;
47use crate::error::{Result, RwError};
48use crate::optimizer::plan_node::generic::GenericPlanRef;
49use crate::optimizer::plan_node::{StreamPlanRef as PlanRef, reorganize_elements_id};
50use crate::optimizer::variant_key::variant_key_error;
51use crate::stream_fragmenter::parallelism::{
52    ResolvedParallelism, derive_backfill_parallelism, derive_parallelism,
53};
54
55/// The mutable state when building fragment graph.
56#[derive(Educe)]
57#[educe(Default)]
58pub struct BuildFragmentGraphState {
59    /// fragment graph field, transformed from input streaming plan.
60    fragment_graph: StreamFragmentGraph,
61    /// local fragment id
62    next_local_fragment_id: FragmentId,
63
64    /// Next local table id to be allocated. It equals to total table ids cnt when finish stream
65    /// node traversing.
66    next_table_id: u32,
67
68    /// rewrite will produce new operators, and we need to track next operator id
69    #[educe(Default(expression = u32::MAX - 1))]
70    next_operator_id: u32,
71
72    /// dependent streaming job ids.
73    dependent_table_ids: HashSet<TableId>,
74
75    /// operator id to `LocalFragmentId` mapping used by share operator.
76    share_mapping: HashMap<StreamNodeLocalOperatorId, LocalFragmentId>,
77    /// operator id to `StreamNode` mapping used by share operator.
78    share_stream_node_mapping: HashMap<StreamNodeLocalOperatorId, StreamNode>,
79
80    has_source_backfill: bool,
81    has_snapshot_backfill: bool,
82    has_cross_db_snapshot_backfill: bool,
83    has_any_backfill: bool,
84}
85
86impl BuildFragmentGraphState {
87    /// Create a new stream fragment with given node with generating a fragment id.
88    fn new_stream_fragment(&mut self) -> StreamFragment {
89        let fragment = StreamFragment::new(self.next_local_fragment_id);
90        self.next_local_fragment_id += 1;
91        fragment
92    }
93
94    /// Generate an operator id
95    pub(crate) fn gen_operator_id(&mut self) -> StreamNodeLocalOperatorId {
96        self.next_operator_id -= 1;
97        LocalOperatorId::new(self.next_operator_id).into()
98    }
99
100    /// Generate an table id
101    pub fn gen_table_id(&mut self) -> u32 {
102        let ret = self.next_table_id;
103        self.next_table_id += 1;
104        ret
105    }
106
107    /// Generate an table id
108    pub fn gen_table_id_wrapped(&mut self) -> TableId {
109        TableId::new(self.gen_table_id())
110    }
111
112    pub fn add_share_stream_node(
113        &mut self,
114        operator_id: StreamNodeLocalOperatorId,
115        stream_node: StreamNode,
116    ) {
117        self.share_stream_node_mapping
118            .insert(operator_id, stream_node);
119    }
120
121    pub fn get_share_stream_node(
122        &mut self,
123        operator_id: StreamNodeLocalOperatorId,
124    ) -> Option<&StreamNode> {
125        self.share_stream_node_mapping.get(&operator_id)
126    }
127
128    /// Generate a new stream node with `NoOp` body and the given `input`. The properties of the
129    /// stream node will also be copied from the `input` node.
130    pub fn gen_no_op_stream_node(&mut self, input: StreamNode) -> StreamNode {
131        StreamNode {
132            operator_id: self.gen_operator_id(),
133            identity: "StreamNoOp".into(),
134            node_body: Some(NodeBody::NoOp(NoOpNode {})),
135
136            // Take input's properties.
137            stream_key: input.stream_key.clone(),
138            stream_kind: input.stream_kind,
139            fields: input.fields.clone(),
140
141            input: vec![input],
142        }
143    }
144}
145
146// The type of streaming job. It is used to determine the parallelism of the job during `build_graph`.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum GraphJobType {
149    Table,
150    MaterializedView,
151    Source,
152    Sink,
153    Index,
154}
155
156impl GraphJobType {
157    pub fn to_parallelism(self, config: &SessionConfig) -> ConfigParallelism {
158        match self {
159            GraphJobType::Table => config.streaming_parallelism_for_table(),
160            GraphJobType::MaterializedView => config.streaming_parallelism_for_materialized_view(),
161            GraphJobType::Source => config.streaming_parallelism_for_source(),
162            GraphJobType::Sink => config.streaming_parallelism_for_sink(),
163            GraphJobType::Index => config.streaming_parallelism_for_index(),
164        }
165    }
166}
167
168pub fn build_graph(
169    plan_node: PlanRef,
170    job_type: Option<GraphJobType>,
171) -> Result<StreamFragmentGraphProto> {
172    build_graph_with_strategy(plan_node, job_type, None)
173}
174
175pub fn build_graph_with_strategy(
176    plan_node: PlanRef,
177    job_type: Option<GraphJobType>,
178    backfill_order: Option<BackfillOrder>,
179) -> Result<StreamFragmentGraphProto> {
180    let ctx = plan_node.plan_base().ctx();
181    let plan_node = reorganize_elements_id(plan_node);
182
183    let mut state = BuildFragmentGraphState::default();
184    let mut stream_node = plan_node.to_stream_prost(&mut state)?;
185    wire_iceberg_update_lists(&mut stream_node)?;
186    reject_variant_in_internal_storage_key(&mut stream_node)?;
187    generate_fragment_graph(&mut state, stream_node)?;
188    if state.has_source_backfill && state.has_snapshot_backfill {
189        return Err(RwError::from(NotSupported(
190            "Snapshot backfill with shared source backfill is not supported".to_owned(),
191            "`SET streaming_use_shared_source = false` to disable shared source backfill, or \
192                    `SET streaming_use_snapshot_backfill = false` to disable snapshot backfill"
193                .to_owned(),
194        )));
195    }
196    if state.has_cross_db_snapshot_backfill
197        && let Some(ref backfill_order) = backfill_order
198        && !backfill_order.order.is_empty()
199    {
200        return Err(RwError::from(NotSupported(
201            "Backfill order control with cross-db snapshot backfill is not supported".to_owned(),
202            "Please remove backfill order specification from your query".to_owned(),
203        )));
204    }
205
206    let (
207        normal_parallelism,
208        backfill_parallelism,
209        adaptive_parallelism_strategy,
210        backfill_adaptive_parallelism_strategy,
211        max_parallelism,
212    ) = {
213        let config = ctx.session_ctx().config();
214        let streaming_parallelism = config.streaming_parallelism();
215        let job_parallelism = job_type.map(|t| t.to_parallelism(config.deref()));
216        let normal_parallelism =
217            derive_parallelism(job_type, job_parallelism, streaming_parallelism);
218        let backfill_parallelism = if state.has_any_backfill {
219            derive_backfill_parallelism(config.streaming_parallelism_for_backfill())
220        } else {
221            ResolvedParallelism {
222                parallelism: None,
223                adaptive_strategy: None,
224            }
225        };
226        (
227            normal_parallelism.parallelism,
228            backfill_parallelism.parallelism,
229            normal_parallelism
230                .adaptive_strategy
231                .as_ref()
232                .map(AdaptiveParallelismStrategy::to_string)
233                .unwrap_or_default(),
234            backfill_parallelism
235                .adaptive_strategy
236                .as_ref()
237                .map(AdaptiveParallelismStrategy::to_string)
238                .unwrap_or_default(),
239            config.streaming_max_parallelism() as _,
240        )
241    };
242
243    let config_override = ctx
244        .session_ctx()
245        .config()
246        .to_initial_streaming_config_override()
247        .context("invalid initial streaming config override")?;
248    let fragments = state
249        .fragment_graph
250        .fragments
251        .into_iter()
252        .map(|(k, v)| (k, v.to_protobuf()))
253        .collect();
254    let edges = state.fragment_graph.edges.into_values().collect();
255
256    Ok(StreamFragmentGraphProto {
257        fragments,
258        edges,
259        dependent_table_ids: state.dependent_table_ids.into_iter().collect(),
260        table_ids_cnt: state.next_table_id,
261        ctx: Some(StreamContext {
262            timezone: ctx.get_session_timezone(),
263            config_override,
264        }),
265        parallelism: normal_parallelism,
266        backfill_parallelism,
267        adaptive_parallelism_strategy,
268        backfill_adaptive_parallelism_strategy,
269        max_parallelism,
270        backfill_order,
271    })
272}
273
274/// Pair each update List with its own Fetch before exchanges split them into fragments.
275/// Source IDs are not job IDs: two scans of the same source must not share completion state.
276fn wire_iceberg_update_lists(node: &mut StreamNode) -> Result<()> {
277    for input in &mut node.input {
278        wire_iceberg_update_lists(input)?;
279    }
280    let Some(NodeBody::StreamFsFetch(fetch)) = &node.node_body else {
281        return Ok(());
282    };
283    let Some(fetch) = &fetch.node_inner else {
284        return Ok(());
285    };
286    if !fetch.with_properties.is_iceberg_update_source() {
287        return Ok(());
288    }
289    let table = fetch
290        .state_table
291        .as_ref()
292        .context("missing Iceberg Fetch state table")?;
293    // Only a reference: meta fills the descriptor from the Fetch table catalog.
294    let desc = risingwave_pb::plan_common::StorageTableDesc {
295        table_id: table.id,
296        ..Default::default()
297    };
298    let mut matched = 0;
299    for input in &mut node.input {
300        risingwave_common::util::stream_graph_visitor::visit_stream_node_mut(input, |body| {
301            if let NodeBody::Source(source) = body
302                && let Some(source) = &mut source.source_inner
303                && source.source_id == fetch.source_id
304                && source.with_properties.is_iceberg_update_source()
305                && source
306                    .downstream_columns
307                    .as_ref()
308                    .is_some_and(|columns| columns.columns == fetch.columns)
309                && source.iceberg_fetch_state_table.is_none()
310            {
311                source.iceberg_fetch_state_table = Some(desc.clone());
312                matched += 1;
313            }
314        });
315    }
316    if matched != 1 {
317        return Err(NotSupported(
318            "Iceberg update Fetch requires exactly one dedicated matching List".to_owned(),
319            "Shared List state is not supported".to_owned(),
320        )
321        .into());
322    }
323    Ok(())
324}
325
326/// Rejects `VARIANT` (including nested) in the storage pk of any internal state table. Internal
327/// tables only materialize when the plan is lowered to protobuf, so this is the single point that
328/// backstops operators whose state keys no logical checker visits.
329fn reject_variant_in_internal_storage_key(stream_node: &mut StreamNode) -> Result<()> {
330    let mut err = None;
331    visit_stream_node_internal_tables(stream_node, |table, table_name| {
332        if err.is_some() {
333            return;
334        }
335        for order in &table.pk {
336            let column = &table.columns[order.column_index as usize];
337            let column_desc = column.column_desc.as_ref().unwrap();
338            let data_type: DataType = column_desc.column_type.as_ref().unwrap().into();
339            if data_type.contains_variant() {
340                err = Some(variant_key_error(format!(
341                    "VARIANT column \"{}\" is part of the storage primary key of the internal \
342                    state table of `{}`",
343                    column_desc.name, table_name,
344                )));
345                return;
346            }
347        }
348    });
349    match err {
350        Some(err) => Err(err),
351        None => Ok(()),
352    }
353}
354
355#[cfg(any())]
356fn is_stateful_executor(stream_node: &StreamNode) -> bool {
357    matches!(
358        stream_node.get_node_body().unwrap(),
359        NodeBody::HashAgg(_)
360            | NodeBody::HashJoin(_)
361            | NodeBody::DeltaIndexJoin(_)
362            | NodeBody::StreamScan(_)
363            | NodeBody::StreamCdcScan(_)
364            | NodeBody::DynamicFilter(_)
365    )
366}
367
368/// Do some dirty rewrites before building the fragments.
369/// Currently, it will split the fragment with multiple stateful operators (those have high I/O
370/// throughput) into multiple fragments, which may help improve the I/O concurrency.
371/// Known as "no-shuffle exchange" or "1v1 exchange".
372#[cfg(any())]
373fn rewrite_stream_node(
374    state: &mut BuildFragmentGraphState,
375    stream_node: StreamNode,
376    insert_exchange_flag: bool,
377) -> Result<StreamNode> {
378    let f = |child| {
379        // For stateful operators, set `exchange_flag = true`. If it's already true,
380        // force add an exchange.
381        if is_stateful_executor(&child) {
382            if insert_exchange_flag {
383                let child_node = rewrite_stream_node(state, child, true)?;
384
385                let strategy = DispatchStrategy {
386                    r#type: DispatcherType::NoShuffle.into(),
387                    dist_key_indices: vec![], // TODO: use distribution key
388                    output_indices: (0..(child_node.fields.len() as u32)).collect(),
389                };
390                Ok(StreamNode {
391                    stream_key: child_node.stream_key.clone(),
392                    fields: child_node.fields.clone(),
393                    node_body: Some(NodeBody::Exchange(ExchangeNode {
394                        strategy: Some(strategy),
395                    })),
396                    operator_id: state.gen_operator_id(),
397                    append_only: child_node.append_only,
398                    input: vec![child_node],
399                    identity: "Exchange (NoShuffle)".to_string(),
400                })
401            } else {
402                rewrite_stream_node(state, child, true)
403            }
404        } else {
405            match child.get_node_body()? {
406                // For exchanges, reset the flag.
407                NodeBody::Exchange(_) => rewrite_stream_node(state, child, false),
408                // Otherwise, recursively visit the children.
409                _ => rewrite_stream_node(state, child, insert_exchange_flag),
410            }
411        }
412    };
413    Ok(StreamNode {
414        input: stream_node
415            .input
416            .into_iter()
417            .map(f)
418            .collect::<Result<_>>()?,
419        ..stream_node
420    })
421}
422
423/// Generate fragment DAG from input streaming plan by their dependency.
424fn generate_fragment_graph(
425    state: &mut BuildFragmentGraphState,
426    stream_node: StreamNode,
427) -> Result<()> {
428    // TODO: the 1v1 exchange is disabled for now, as it breaks the assumption of independent
429    // scaling of fragments. We may introduce further optimization transparently to the fragmenter.
430    // #4614
431    #[cfg(any())]
432    let stream_node = rewrite_stream_node(state, stream_node, is_stateful_executor(&stream_node))?;
433
434    build_and_add_fragment(state, stream_node)?;
435    Ok(())
436}
437
438/// Use the given `stream_node` to create a fragment and add it to graph.
439fn build_and_add_fragment(
440    state: &mut BuildFragmentGraphState,
441    stream_node: StreamNode,
442) -> Result<Rc<StreamFragment>> {
443    let operator_id = stream_node.operator_id;
444    match state.share_mapping.get(&operator_id) {
445        None => {
446            let mut fragment = state.new_stream_fragment();
447            let node = build_fragment(state, &mut fragment, stream_node)?;
448
449            // It's possible that the stream node is rewritten while building the fragment, for
450            // example, empty fragment to no-op fragment. We get the operator id again instead of
451            // using the original one.
452            let operator_id = node.operator_id;
453
454            assert!(fragment.node.is_none());
455            fragment.node = Some(Box::new(node));
456            let fragment_ref = Rc::new(fragment);
457
458            state.fragment_graph.add_fragment(fragment_ref.clone());
459            state
460                .share_mapping
461                .insert(operator_id, fragment_ref.fragment_id);
462            Ok(fragment_ref)
463        }
464        Some(fragment_id) => Ok(state
465            .fragment_graph
466            .get_fragment(fragment_id)
467            .unwrap()
468            .clone()),
469    }
470}
471
472/// Build new fragment and link dependencies by visiting children recursively, update
473/// `requires_singleton` and `fragment_type` properties for current fragment.
474fn build_fragment(
475    state: &mut BuildFragmentGraphState,
476    current_fragment: &mut StreamFragment,
477    mut stream_node: StreamNode,
478) -> Result<StreamNode> {
479    recursive::tracker!().recurse(|_t| {
480        // Update current fragment based on the node we're visiting.
481        match stream_node.get_node_body()? {
482            NodeBody::BarrierRecv(_) => current_fragment
483                .fragment_type_mask
484                .add(FragmentTypeFlag::BarrierRecv),
485
486            NodeBody::Source(node) => {
487                current_fragment
488                    .fragment_type_mask
489                    .add(FragmentTypeFlag::Source);
490
491                if let Some(source) = node.source_inner.as_ref()
492                    && let Some(source_info) = source.info.as_ref()
493                    && ((source_info.is_shared() && !source_info.is_distributed)
494                        || source.with_properties.requires_singleton())
495                {
496                    current_fragment.requires_singleton = true;
497                }
498            }
499
500            NodeBody::Dml(_) => {
501                current_fragment
502                    .fragment_type_mask
503                    .add(FragmentTypeFlag::Dml);
504            }
505
506            NodeBody::Materialize(_) => {
507                current_fragment
508                    .fragment_type_mask
509                    .add(FragmentTypeFlag::Mview);
510            }
511
512            NodeBody::Sink(_) => current_fragment
513                .fragment_type_mask
514                .add(FragmentTypeFlag::Sink),
515
516            NodeBody::TopN(_) => current_fragment.requires_singleton = true,
517
518            NodeBody::StreamScan(node) => {
519                current_fragment
520                    .fragment_type_mask
521                    .add(FragmentTypeFlag::StreamScan);
522                #[expect(deprecated)]
523                match node.stream_scan_type() {
524                    StreamScanType::SnapshotBackfill => {
525                        current_fragment
526                            .fragment_type_mask
527                            .add(FragmentTypeFlag::SnapshotBackfillStreamScan);
528                        state.has_snapshot_backfill = true;
529                        state.has_any_backfill = true;
530                    }
531                    StreamScanType::Backfill | StreamScanType::ArrangementBackfill => {
532                        state.has_any_backfill = true;
533                    }
534                    StreamScanType::CrossDbSnapshotBackfill => {
535                        current_fragment
536                            .fragment_type_mask
537                            .add(FragmentTypeFlag::CrossDbSnapshotBackfillStreamScan);
538                        state.has_cross_db_snapshot_backfill = true;
539                        state.has_any_backfill = true;
540                    }
541                    StreamScanType::Unspecified
542                    | StreamScanType::Chain
543                    | StreamScanType::Rearrange
544                    | StreamScanType::UpstreamOnly => {}
545                }
546                // memorize table id for later use
547                // The table id could be a upstream CDC source
548                state.dependent_table_ids.insert(node.table_id);
549            }
550
551            NodeBody::StreamCdcScan(node) => {
552                current_fragment
553                    .fragment_type_mask
554                    .add(FragmentTypeFlag::StreamCdcScan);
555                let is_parallelized_backfill = node
556                    .options
557                    .as_ref()
558                    .is_some_and(|o| CdcScanOptions::from_proto(o).is_parallelized_backfill());
559                if !is_parallelized_backfill {
560                    current_fragment
561                        .fragment_type_mask
562                        .add(FragmentTypeFlag::StreamScan);
563                    // the backfill algorithm is not parallel safe
564                    current_fragment.requires_singleton = true;
565                }
566                state.has_source_backfill = true;
567                state.has_any_backfill = true;
568            }
569
570            NodeBody::CdcFilter(node) => {
571                current_fragment
572                    .fragment_type_mask
573                    .add(FragmentTypeFlag::CdcFilter);
574                // memorize upstream source id for later use
575                state
576                    .dependent_table_ids
577                    .insert(node.upstream_source_id.as_cdc_table_id());
578            }
579            NodeBody::SourceBackfill(node) => {
580                current_fragment
581                    .fragment_type_mask
582                    .add(FragmentTypeFlag::SourceScan);
583                // memorize upstream source id for later use
584                let source_id = node.upstream_source_id;
585                state
586                    .dependent_table_ids
587                    .insert(source_id.as_cdc_table_id());
588                state.has_source_backfill = true;
589                state.has_any_backfill = true;
590            }
591
592            NodeBody::Now(_) => {
593                // TODO: Remove this and insert a `BarrierRecv` instead.
594                current_fragment
595                    .fragment_type_mask
596                    .add(FragmentTypeFlag::Now);
597                current_fragment.requires_singleton = true;
598            }
599
600            NodeBody::Values(_) => {
601                current_fragment
602                    .fragment_type_mask
603                    .add(FragmentTypeFlag::Values);
604                current_fragment.requires_singleton = true;
605            }
606
607            NodeBody::CompactionResolver(_) => {
608                current_fragment.requires_singleton = true;
609            }
610
611            NodeBody::StreamFsFetch(_) => {
612                current_fragment
613                    .fragment_type_mask
614                    .add(FragmentTypeFlag::FsFetch);
615            }
616
617            NodeBody::VectorIndexWrite(_) => {
618                current_fragment
619                    .fragment_type_mask
620                    .add(FragmentTypeFlag::VectorIndexWrite);
621            }
622
623            NodeBody::IcebergWithPkIndexWriter(_) => {
624                current_fragment
625                    .fragment_type_mask
626                    .add(FragmentTypeFlag::IcebergWithPkIndexWriter);
627            }
628
629            NodeBody::UpstreamSinkUnion(_) => {
630                current_fragment
631                    .fragment_type_mask
632                    .add(FragmentTypeFlag::UpstreamSinkUnion);
633            }
634
635            NodeBody::LocalityProvider(_) => {
636                current_fragment
637                    .fragment_type_mask
638                    .add(FragmentTypeFlag::LocalityProvider);
639            }
640
641            _ => {}
642        };
643
644        // handle join logic
645        if let NodeBody::DeltaIndexJoin(delta_index_join) = stream_node.node_body.as_mut().unwrap()
646        {
647            if delta_index_join.get_join_type()? == JoinType::Inner
648                && delta_index_join.condition.is_none()
649            {
650                return build_delta_join_without_arrange(state, current_fragment, stream_node);
651            } else {
652                panic!("only inner join without non-equal condition is supported for delta joins");
653            }
654        }
655
656        // Usually we do not expect exchange node to be visited here, which should be handled by the
657        // following logic of "visit children" instead. If it does happen (for example, `Share` will be
658        // transformed to an `Exchange`), it means we have an empty fragment and we need to add a no-op
659        // node to it, so that the meta service can handle it correctly.
660        if let NodeBody::Exchange(_) = stream_node.node_body.as_ref().unwrap() {
661            stream_node = state.gen_no_op_stream_node(stream_node);
662        }
663
664        // Visit plan children.
665        stream_node.input = stream_node
666            .input
667            .into_iter()
668            .map(|mut child_node| {
669                match child_node.get_node_body()? {
670                    // When exchange node is generated when doing rewrites, it could be having
671                    // zero input. In this case, we won't recursively visit its children.
672                    NodeBody::Exchange(_) if child_node.input.is_empty() => Ok(child_node),
673                    // Exchange node indicates a new child fragment.
674                    NodeBody::Exchange(exchange_node) => {
675                        let exchange_node_strategy = exchange_node.get_strategy()?.clone();
676
677                        // Exchange node should have only one input.
678                        let [input]: [_; 1] =
679                            std::mem::take(&mut child_node.input).try_into().unwrap();
680                        let child_fragment = build_and_add_fragment(state, input)?;
681
682                        let result = state.fragment_graph.try_add_edge(
683                            child_fragment.fragment_id,
684                            current_fragment.fragment_id,
685                            StreamFragmentEdge {
686                                dispatch_strategy: exchange_node_strategy.clone(),
687                                // Always use the exchange operator id as the link id.
688                                link_id: child_node.operator_id.as_raw_id(),
689                            },
690                        );
691
692                        // It's possible that there're multiple edges between two fragments, while the
693                        // meta service and the compute node does not expect this. In this case, we
694                        // manually insert a fragment of `NoOp` between the two fragments.
695                        if result.is_err() {
696                            // Assign a new operator id for the `Exchange`, so we can distinguish it
697                            // from duplicate edges and break the sharing.
698                            child_node.operator_id = state.gen_operator_id();
699
700                            // Take the upstream plan node as the reference for properties of `NoOp`.
701                            let ref_fragment_node = child_fragment.node.as_ref().unwrap();
702                            let no_shuffle_strategy = DispatchStrategy {
703                                r#type: DispatcherType::NoShuffle as i32,
704                                dist_key_indices: vec![],
705                                output_mapping: PbDispatchOutputMapping::identical(
706                                    ref_fragment_node.fields.len(),
707                                )
708                                .into(),
709                            };
710
711                            let no_shuffle_exchange_operator_id = state.gen_operator_id();
712
713                            let no_op_fragment = {
714                                let node = state.gen_no_op_stream_node(StreamNode {
715                                    operator_id: no_shuffle_exchange_operator_id,
716                                    identity: "StreamNoShuffleExchange".into(),
717                                    node_body: Some(NodeBody::Exchange(Box::new(ExchangeNode {
718                                        strategy: Some(no_shuffle_strategy.clone()),
719                                    }))),
720                                    input: vec![],
721
722                                    // Take reference's properties.
723                                    stream_key: ref_fragment_node.stream_key.clone(),
724                                    stream_kind: ref_fragment_node.stream_kind,
725                                    fields: ref_fragment_node.fields.clone(),
726                                });
727
728                                let mut fragment = state.new_stream_fragment();
729                                fragment.node = Some(node.into());
730                                Rc::new(fragment)
731                            };
732
733                            state.fragment_graph.add_fragment(no_op_fragment.clone());
734
735                            state.fragment_graph.add_edge(
736                                child_fragment.fragment_id,
737                                no_op_fragment.fragment_id,
738                                StreamFragmentEdge {
739                                    // Use `NoShuffle` exhcnage strategy for upstream edge.
740                                    dispatch_strategy: no_shuffle_strategy,
741                                    link_id: no_shuffle_exchange_operator_id.as_raw_id(),
742                                },
743                            );
744                            state.fragment_graph.add_edge(
745                                no_op_fragment.fragment_id,
746                                current_fragment.fragment_id,
747                                StreamFragmentEdge {
748                                    // Use the original exchange strategy for downstream edge.
749                                    dispatch_strategy: exchange_node_strategy,
750                                    link_id: child_node.operator_id.as_raw_id(),
751                                },
752                            );
753                        }
754
755                        Ok(child_node)
756                    }
757
758                    // For other children, visit recursively.
759                    _ => build_fragment(state, current_fragment, child_node),
760                }
761            })
762            .collect::<Result<_>>()?;
763        Ok(stream_node)
764    })
765}
766
767#[cfg(test)]
768mod tests {
769    use risingwave_common::types::StructType;
770    use risingwave_common::util::iter_util::ZipEqFast;
771    use risingwave_pb::catalog::PbTable;
772    use risingwave_pb::common::PbColumnOrder;
773    use risingwave_pb::plan_common::{PbColumnCatalog, PbColumnDesc};
774    use risingwave_pb::stream_plan::TopNNode;
775
776    use super::*;
777
778    #[test]
779    fn iceberg_update_lists_use_per_fetch_tables_not_source_ids() -> Result<()> {
780        use risingwave_pb::stream_plan::{
781            Columns, SourceNode, StreamFsFetch, StreamFsFetchNode, StreamSource,
782        };
783        let props = std::collections::BTreeMap::from([
784            ("connector".to_owned(), "iceberg".to_owned()),
785            ("streaming_updates".to_owned(), "true".to_owned()),
786        ]);
787        let pair = |table_id: u32| StreamNode {
788            node_body: Some(NodeBody::StreamFsFetch(Box::new(StreamFsFetchNode {
789                node_inner: Some(StreamFsFetch {
790                    source_id: 9.into(),
791                    with_properties: props.clone(),
792                    state_table: Some(PbTable {
793                        id: table_id.into(),
794                        columns: vec![PbColumnCatalog {
795                            column_desc: Some(PbColumnDesc {
796                                name: "task".to_owned(),
797                                column_type: Some(DataType::Varchar.to_protobuf()),
798                                ..Default::default()
799                            }),
800                            ..Default::default()
801                        }],
802                        pk: vec![PbColumnOrder {
803                            column_index: 0,
804                            order_type: None,
805                        }],
806                        distribution_key: vec![0],
807                        value_indices: vec![0],
808                        ..Default::default()
809                    }),
810                    ..Default::default()
811                }),
812            }))),
813            input: vec![StreamNode {
814                node_body: Some(NodeBody::Source(Box::new(SourceNode {
815                    source_inner: Some(StreamSource {
816                        source_id: 9.into(),
817                        with_properties: props.clone(),
818                        downstream_columns: Some(Columns { columns: vec![] }),
819                        ..Default::default()
820                    }),
821                }))),
822                ..Default::default()
823            }],
824            ..Default::default()
825        };
826        let mut root = StreamNode {
827            input: vec![pair(10), pair(11)],
828            ..Default::default()
829        };
830        wire_iceberg_update_lists(&mut root)?;
831        for (node, id) in root.input.iter().zip_eq_fast([10u32, 11]) {
832            let Some(NodeBody::Source(source)) = &node.input[0].node_body else {
833                panic!("missing List")
834            };
835            // Only the reference is planned; meta derives the rest from the Fetch catalog.
836            assert_eq!(
837                source
838                    .source_inner
839                    .as_ref()
840                    .unwrap()
841                    .iceberg_fetch_state_table,
842                Some(risingwave_pb::plan_common::StorageTableDesc {
843                    table_id: id.into(),
844                    ..Default::default()
845                })
846            );
847        }
848        let mut no_list = pair(12);
849        no_list.input.clear();
850        assert!(wire_iceberg_update_lists(&mut no_list).is_err());
851        let mut shared_list = pair(13);
852        shared_list.input.push(shared_list.input[0].clone());
853        assert!(wire_iceberg_update_lists(&mut shared_list).is_err());
854        Ok(())
855    }
856
857    /// A `TopN` node is the simplest body carrying exactly one internal table.
858    fn top_n_with_pk_column(data_type: DataType) -> StreamNode {
859        let table = PbTable {
860            columns: vec![PbColumnCatalog {
861                column_desc: Some(PbColumnDesc {
862                    name: "v".to_owned(),
863                    column_type: Some(data_type.to_protobuf()),
864                    ..Default::default()
865                }),
866                ..Default::default()
867            }],
868            pk: vec![PbColumnOrder {
869                column_index: 0,
870                order_type: None,
871            }],
872            ..Default::default()
873        };
874        StreamNode {
875            node_body: Some(NodeBody::TopN(Box::new(TopNNode {
876                table: Some(table),
877                ..Default::default()
878            }))),
879            ..Default::default()
880        }
881    }
882
883    #[test]
884    fn rejects_variant_in_internal_state_table_pk() {
885        for data_type in [
886            DataType::Variant,
887            DataType::list(DataType::Variant),
888            DataType::Struct(StructType::new(vec![("v", DataType::Variant)])),
889        ] {
890            let mut node = top_n_with_pk_column(data_type.clone());
891            let err = reject_variant_in_internal_storage_key(&mut node).unwrap_err();
892            assert!(
893                err.to_string().contains("internal state table of `TopN`"),
894                "{data_type:?}: {err}"
895            );
896        }
897
898        let mut node = top_n_with_pk_column(DataType::Jsonb);
899        reject_variant_in_internal_storage_key(&mut node).unwrap();
900    }
901}