1use std::collections::{BTreeMap, HashMap};
16
17use assert_matches::assert_matches;
18use risingwave_common::bail;
19use risingwave_common::catalog::TableDesc;
20use risingwave_common::hash::{IsSingleton, VnodeCount, VnodeCountCompat};
21use risingwave_common::util::iter_util::ZipEqFast;
22use risingwave_common::util::stream_graph_visitor::{
23 visit_fragment, visit_iceberg_completion_tables, visit_tables,
24};
25use risingwave_pb::stream_plan::stream_node::NodeBody;
26use risingwave_pb::stream_plan::{
27 DispatchStrategy, DispatcherType, MergeNode, StreamNode, StreamScanType,
28};
29
30use crate::MetaResult;
31use crate::model::{Fragment, FragmentDownstreamRelation, FragmentId, FragmentReplaceUpstream};
32use crate::stream::stream_graph::fragment::{
33 CompleteStreamFragmentGraph, DownstreamExternalEdgeId, EdgeId, EitherFragment,
34 StreamFragmentEdge,
35};
36use crate::stream::stream_graph::id::GlobalFragmentId;
37use crate::stream::stream_graph::schedule;
38use crate::stream::stream_graph::schedule::Distribution;
39
40impl FragmentActorBuilder {
41 fn rewrite(&self) -> MetaResult<StreamNode> {
47 self.rewrite_inner(&self.node, 0)
48 }
49
50 fn rewrite_inner(&self, stream_node: &StreamNode, depth: usize) -> MetaResult<StreamNode> {
51 match stream_node.get_node_body()? {
52 NodeBody::Exchange(exchange) => {
54 if depth == 0 {
57 bail!(
58 "there should be no ExchangeNode on the top of the plan node: {:#?}",
59 stream_node
60 )
61 }
62 assert!(!stream_node.get_fields().is_empty());
63 assert!(stream_node.input.is_empty());
64
65 let (upstream_fragment_id, _) = &self.upstreams[&EdgeId::Internal {
67 link_id: stream_node.get_operator_id().as_raw_id(),
68 }];
69
70 let upstream_fragment_id = upstream_fragment_id.as_global_id();
71
72 Ok(StreamNode {
73 node_body: Some(NodeBody::Merge(Box::new({
74 MergeNode {
75 upstream_fragment_id,
76 upstream_dispatcher_type: exchange.get_strategy()?.r#type,
77 ..Default::default()
78 }
79 }))),
80 identity: "MergeExecutor".to_owned(),
81 ..stream_node.clone()
82 })
83 }
84
85 NodeBody::StreamScan(stream_scan) => {
87 let input = stream_node.get_input();
88 if stream_scan.stream_scan_type() == StreamScanType::CrossDbSnapshotBackfill {
89 assert!(input.is_empty());
92 return Ok(stream_node.clone());
93 }
94 assert_eq!(input.len(), 2);
95
96 let merge_node = &input[0];
97 assert_matches!(merge_node.node_body, Some(NodeBody::Merge(_)));
98 let batch_plan_node = &input[1];
99 assert_matches!(batch_plan_node.node_body, Some(NodeBody::BatchPlan(_)));
100
101 let (upstream_fragment_id, upstream_no_shuffle_actor) = &self.upstreams
103 [&EdgeId::UpstreamExternal {
104 upstream_job_id: stream_scan.table_id.as_job_id(),
105 downstream_fragment_id: self.fragment_id,
106 }];
107
108 let is_shuffled_backfill = stream_scan.stream_scan_type
109 == StreamScanType::ArrangementBackfill as i32
110 || stream_scan.stream_scan_type == StreamScanType::SnapshotBackfill as i32;
111 if !is_shuffled_backfill {
112 assert!(*upstream_no_shuffle_actor);
113 }
114
115 let upstream_dispatcher_type = if is_shuffled_backfill {
116 DispatcherType::Hash as _
119 } else {
120 DispatcherType::NoShuffle as _
121 };
122
123 let upstream_fragment_id = upstream_fragment_id.as_global_id();
124
125 let input = vec![
126 StreamNode {
128 node_body: Some(NodeBody::Merge(Box::new({
129 MergeNode {
130 upstream_fragment_id,
131 upstream_dispatcher_type,
132 ..Default::default()
133 }
134 }))),
135 ..merge_node.clone()
136 },
137 batch_plan_node.clone(),
138 ];
139
140 Ok(StreamNode {
141 input,
142 ..stream_node.clone()
143 })
144 }
145
146 NodeBody::CdcFilter(_) | NodeBody::SourceBackfill(_) => {
150 let input = stream_node.get_input();
151 assert_eq!(input.len(), 1);
152
153 let merge_node = &input[0];
154 assert_matches!(merge_node.node_body, Some(NodeBody::Merge(_)));
155
156 let upstream_source_id = match stream_node.get_node_body()? {
157 NodeBody::CdcFilter(node) => node.upstream_source_id,
158 NodeBody::SourceBackfill(node) => node.upstream_source_id,
159 _ => unreachable!(),
160 };
161
162 let (upstream_fragment_id, upstream_is_no_shuffle) = &self.upstreams
164 [&EdgeId::UpstreamExternal {
165 upstream_job_id: upstream_source_id.as_share_source_job_id(),
166 downstream_fragment_id: self.fragment_id,
167 }];
168
169 assert!(
170 *upstream_is_no_shuffle,
171 "Upstream Cdc Source should be singleton. \
172 SourceBackfill is NoShuffle 1-1 correspondence. \
173 So they both should have only one upstream actor."
174 );
175
176 let upstream_fragment_id = upstream_fragment_id.as_global_id();
177
178 let input = vec![
180 StreamNode {
182 node_body: Some(NodeBody::Merge(Box::new({
183 MergeNode {
184 upstream_fragment_id,
185 upstream_dispatcher_type: DispatcherType::NoShuffle as _,
186 ..Default::default()
187 }
188 }))),
189 ..merge_node.clone()
190 },
191 ];
192 Ok(StreamNode {
193 input,
194 ..stream_node.clone()
195 })
196 }
197
198 _ => {
200 let mut new_stream_node = stream_node.clone();
201 for (input, new_input) in stream_node
202 .input
203 .iter()
204 .zip_eq_fast(&mut new_stream_node.input)
205 {
206 *new_input = self.rewrite_inner(input, depth + 1)?;
207 }
208 Ok(new_stream_node)
209 }
210 }
211 }
212}
213
214#[derive(Default)]
219struct UpstreamFragmentChange {
220 new_downstreams: HashMap<GlobalFragmentId, DispatchStrategy>,
222}
223
224#[derive(Default)]
225struct DownstreamFragmentChange {
226 new_upstreams: HashMap<DownstreamExternalEdgeId, GlobalFragmentId>,
229}
230
231impl UpstreamFragmentChange {
232 fn add_dispatcher(
234 &mut self,
235 downstream_fragment_id: GlobalFragmentId,
236 dispatch: DispatchStrategy,
237 ) {
238 self.new_downstreams
239 .try_insert(downstream_fragment_id, dispatch)
240 .unwrap();
241 }
242}
243
244impl DownstreamFragmentChange {
245 fn add_upstream(
247 &mut self,
248 edge_id: DownstreamExternalEdgeId,
249 new_upstream_fragment_id: GlobalFragmentId,
250 ) {
251 self.new_upstreams
252 .try_insert(edge_id, new_upstream_fragment_id)
253 .unwrap();
254 }
255}
256
257#[derive(Debug)]
258struct FragmentActorBuilder {
259 fragment_id: GlobalFragmentId,
260 node: StreamNode,
261 downstreams: HashMap<GlobalFragmentId, DispatchStrategy>,
262 upstreams: HashMap<EdgeId, (GlobalFragmentId, bool)>,
264}
265
266impl FragmentActorBuilder {
267 fn new(fragment_id: GlobalFragmentId, node: StreamNode) -> Self {
268 Self {
269 fragment_id,
270 node,
271 downstreams: Default::default(),
272 upstreams: Default::default(),
273 }
274 }
275}
276
277#[derive(Default)]
284struct ActorGraphBuildStateInner {
285 fragment_actor_builders: BTreeMap<GlobalFragmentId, FragmentActorBuilder>,
287
288 downstream_fragment_changes: BTreeMap<GlobalFragmentId, DownstreamFragmentChange>,
291
292 upstream_fragment_changes: BTreeMap<GlobalFragmentId, UpstreamFragmentChange>,
295}
296
297struct FragmentLinkNode {
299 fragment_id: GlobalFragmentId,
300}
301
302impl ActorGraphBuildStateInner {
303 fn add_dispatcher(
308 &mut self,
309 fragment_id: GlobalFragmentId,
310 downstream_fragment_id: GlobalFragmentId,
311 dispatch: DispatchStrategy,
312 ) {
313 if let Some(builder) = self.fragment_actor_builders.get_mut(&fragment_id) {
314 builder
315 .downstreams
316 .try_insert(downstream_fragment_id, dispatch)
317 .unwrap();
318 } else {
319 self.upstream_fragment_changes
320 .entry(fragment_id)
321 .or_default()
322 .add_dispatcher(downstream_fragment_id, dispatch);
323 }
324 }
325
326 fn add_upstream(
331 &mut self,
332 fragment_id: GlobalFragmentId,
333 edge_id: EdgeId,
334 upstream_fragment_id: GlobalFragmentId,
335 is_no_shuffle: bool,
336 ) {
337 if let Some(builder) = self.fragment_actor_builders.get_mut(&fragment_id) {
338 builder
339 .upstreams
340 .try_insert(edge_id, (upstream_fragment_id, is_no_shuffle))
341 .unwrap();
342 } else {
343 let EdgeId::DownstreamExternal(edge_id) = edge_id else {
344 unreachable!("edge from internal to external must be `DownstreamExternal`")
345 };
346 self.downstream_fragment_changes
347 .entry(fragment_id)
348 .or_default()
349 .add_upstream(edge_id, upstream_fragment_id);
350 }
351 }
352
353 fn add_link(
362 &mut self,
363 upstream: FragmentLinkNode,
364 downstream: FragmentLinkNode,
365 edge: &StreamFragmentEdge,
366 ) {
367 let dt = edge.dispatch_strategy.r#type();
368
369 match dt {
370 DispatcherType::NoShuffle => {
372 self.add_dispatcher(
374 upstream.fragment_id,
375 downstream.fragment_id,
376 edge.dispatch_strategy.clone(),
377 );
378
379 self.add_upstream(downstream.fragment_id, edge.id, upstream.fragment_id, true);
381 }
382
383 DispatcherType::Hash | DispatcherType::Broadcast | DispatcherType::Simple => {
385 self.add_dispatcher(
386 upstream.fragment_id,
387 downstream.fragment_id,
388 edge.dispatch_strategy.clone(),
389 );
390 self.add_upstream(downstream.fragment_id, edge.id, upstream.fragment_id, false);
391 }
392
393 DispatcherType::Unspecified => unreachable!(),
394 }
395 }
396}
397
398struct ActorGraphBuildState {
400 inner: ActorGraphBuildStateInner,
402}
403
404impl ActorGraphBuildState {
405 fn new() -> Self {
407 Self {
408 inner: Default::default(),
409 }
410 }
411
412 fn finish(self) -> ActorGraphBuildStateInner {
414 self.inner
415 }
416}
417
418pub struct ActorGraphBuildResult {
421 pub graph: BTreeMap<FragmentId, Fragment>,
423 pub downstream_fragment_relations: FragmentDownstreamRelation,
426
427 pub upstream_fragment_downstreams: FragmentDownstreamRelation,
429
430 pub replace_upstream: FragmentReplaceUpstream,
433}
434
435#[derive(Debug)]
438pub struct ActorGraphBuilder {
439 distributions: HashMap<GlobalFragmentId, Distribution>,
441
442 fragment_graph: CompleteStreamFragmentGraph,
444}
445
446impl ActorGraphBuilder {
447 pub fn new(fragment_graph: CompleteStreamFragmentGraph) -> MetaResult<Self> {
450 let expected_vnode_count = fragment_graph.max_parallelism();
451 let scheduler = schedule::Scheduler::new(expected_vnode_count)?;
452
453 let distributions = scheduler.schedule(&fragment_graph)?;
454
455 let mut fragment_graph = fragment_graph;
457 let mut fetch_tables = HashMap::new();
458 for (id, fragment) in fragment_graph.building_fragments_mut() {
459 let mut error = None;
460 let fragment_vnode_count = distributions[id].vnode_count();
461 visit_tables(fragment, |table, _| {
462 if error.is_some() {
463 return;
464 }
465 let vnode_count = if table.is_singleton() {
468 if fragment_vnode_count > 1 {
469 tracing::info!(
470 table.name,
471 "found singleton table in hash-distributed fragment"
472 );
473 }
474 1
475 } else {
476 fragment_vnode_count
477 };
478 match table.vnode_count_inner().value_opt() {
479 Some(required_vnode_count) if required_vnode_count != vnode_count => {
486 error = Some(format!(
487 "failed to align vnode count for table {}({}): required {}, but got {}",
488 table.id, table.name, required_vnode_count, vnode_count
489 ));
490 }
491 _ => table.maybe_vnode_count = VnodeCount::set(vnode_count).to_protobuf(),
493 }
494 });
495 if let Some(error) = error {
496 bail!(error);
497 }
498 visit_fragment(fragment, |body| {
499 if let NodeBody::StreamFsFetch(node) = body
500 && let Some(table) = node
501 .node_inner
502 .as_ref()
503 .and_then(|source| source.state_table.as_ref())
504 {
505 fetch_tables.insert(table.id, table.clone());
506 }
507 });
508 }
509 for fragment in fragment_graph.building_fragments_mut().values_mut() {
512 let mut result = Ok(());
513 visit_iceberg_completion_tables(fragment, |desc| {
514 if result.is_err() {
515 return;
516 }
517 result = match fetch_tables.get(&desc.table_id) {
518 Some(fetch) => TableDesc::from_pb_table(fetch)
519 .try_to_protobuf()
520 .map(|filled| *desc = filled),
521 None => Err(anyhow::anyhow!(
522 "Iceberg completion table {} must reference a Fetch state table in this ingestion graph",
523 desc.table_id
524 )),
525 };
526 });
527 result?;
528 }
529
530 Ok(Self {
531 distributions,
532 fragment_graph,
533 })
534 }
535
536 pub fn generate_graph(self) -> MetaResult<ActorGraphBuildResult> {
539 let ActorGraphBuildStateInner {
541 fragment_actor_builders,
542 downstream_fragment_changes,
543 upstream_fragment_changes,
544 } = self.build_actor_graph()?;
545
546 let mut downstream_fragment_relations: FragmentDownstreamRelation = HashMap::new();
547 let graph = {
549 let mut fragment_nodes: HashMap<GlobalFragmentId, StreamNode> = HashMap::new();
552
553 for (fragment_id, builder) in fragment_actor_builders {
554 let global_fragment_id = fragment_id.as_global_id();
555 let node = builder.rewrite()?;
556 downstream_fragment_relations
557 .try_insert(
558 global_fragment_id,
559 builder
560 .downstreams
561 .into_iter()
562 .map(|(id, dispatch)| (id.as_global_id(), dispatch).into())
563 .collect(),
564 )
565 .expect("non-duplicate");
566 fragment_nodes
567 .try_insert(fragment_id, node)
568 .expect("non-duplicate");
569 }
570
571 let mut graph = BTreeMap::new();
572 for (fragment_id, stream_node) in fragment_nodes {
573 let distribution = self.distributions[&fragment_id].clone();
574 let fragment =
575 self.fragment_graph
576 .seal_fragment(fragment_id, distribution, stream_node);
577 let fragment_id = fragment_id.as_global_id();
578 graph.insert(fragment_id, fragment);
579 }
580 graph
581 };
582
583 let upstream_fragment_downstreams = upstream_fragment_changes
585 .into_iter()
586 .map(|(fragment_id, changes)| {
587 (
588 fragment_id.as_global_id(),
589 changes
590 .new_downstreams
591 .into_iter()
592 .map(|(downstream_fragment_id, new_dispatch)| {
593 (downstream_fragment_id.as_global_id(), new_dispatch).into()
594 })
595 .collect(),
596 )
597 })
598 .collect();
599
600 let replace_upstream = downstream_fragment_changes
602 .into_iter()
603 .map(|(fragment_id, changes)| {
604 let fragment_id = fragment_id.as_global_id();
605 (
606 fragment_id,
607 changes
608 .new_upstreams
609 .into_iter()
610 .map(move |(edge_id, upstream_fragment_id)| {
611 let upstream_fragment_id = upstream_fragment_id.as_global_id();
612 let DownstreamExternalEdgeId {
613 original_upstream_fragment_id,
614 ..
615 } = edge_id;
616 (
617 original_upstream_fragment_id.as_global_id(),
618 upstream_fragment_id,
619 )
620 })
621 .collect(),
622 )
623 })
624 .filter(|(_, fragment_changes): &(_, HashMap<_, _>)| !fragment_changes.is_empty())
625 .collect();
626
627 Ok(ActorGraphBuildResult {
628 graph,
629 downstream_fragment_relations,
630 upstream_fragment_downstreams,
631 replace_upstream,
632 })
633 }
634
635 fn build_actor_graph(&self) -> MetaResult<ActorGraphBuildStateInner> {
637 let mut state = ActorGraphBuildState::new();
638
639 for fragment_id in self.fragment_graph.topo_order()? {
642 self.build_actor_graph_fragment(fragment_id, &mut state)?;
643 }
644
645 Ok(state.finish())
646 }
647
648 fn build_actor_graph_fragment(
650 &self,
651 fragment_id: GlobalFragmentId,
652 state: &mut ActorGraphBuildState,
653 ) -> MetaResult<()> {
654 let current_fragment = self.fragment_graph.get_fragment(fragment_id);
655
656 match current_fragment {
658 EitherFragment::Building(current_fragment) => {
660 let node = current_fragment.node.clone().unwrap();
661 state
662 .inner
663 .fragment_actor_builders
664 .try_insert(fragment_id, FragmentActorBuilder::new(fragment_id, node))
665 .expect("non-duplicate");
666 }
667
668 EitherFragment::Existing => {}
670 };
671
672 for (downstream_fragment_id, edge) in self.fragment_graph.get_downstreams(fragment_id) {
674 state.inner.add_link(
675 FragmentLinkNode { fragment_id },
676 FragmentLinkNode {
677 fragment_id: downstream_fragment_id,
678 },
679 edge,
680 );
681 }
682
683 Ok(())
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use risingwave_common::catalog::FragmentTypeFlag;
690 use risingwave_common::hash::VirtualNode;
691 use risingwave_common::types::DataType;
692 use risingwave_common::util::sort_util::OrderType;
693 use risingwave_pb::catalog::PbTable;
694 use risingwave_pb::common::PbColumnOrder;
695 use risingwave_pb::ddl_service::TableJobType;
696 use risingwave_pb::plan_common::{PbColumnCatalog, PbColumnDesc, StorageTableDesc};
697 use risingwave_pb::stream_plan::stream_fragment_graph::{
698 StreamFragment, StreamFragmentEdge as StreamFragmentEdgeProto,
699 };
700 use risingwave_pb::stream_plan::{
701 ExchangeNode, PbDispatchOutputMapping, PbStreamContext, SourceNode,
702 StreamFragmentGraph as StreamFragmentGraphProto, StreamFsFetch, StreamFsFetchNode,
703 StreamSource,
704 };
705
706 use super::*;
707 use crate::manager::{MetaSrvEnv, StreamingJob};
708 use crate::stream::StreamFragmentGraph;
709
710 const LIST_TABLE: u32 = 0;
711 const FETCH_TABLE: u32 = 1;
712
713 fn column(name: &str, data_type: DataType) -> PbColumnCatalog {
714 PbColumnCatalog {
715 column_desc: Some(PbColumnDesc {
716 name: name.to_owned(),
717 column_type: Some(data_type.to_protobuf()),
718 ..Default::default()
719 }),
720 ..Default::default()
721 }
722 }
723
724 fn update_graph(reference: u32) -> StreamFragmentGraphProto {
726 let list = StreamNode {
727 node_body: Some(NodeBody::Source(Box::new(SourceNode {
728 source_inner: Some(StreamSource {
729 state_table: Some(PbTable {
730 id: LIST_TABLE.into(),
731 columns: vec![column("state", DataType::Jsonb)],
732 value_indices: vec![0],
733 ..Default::default()
734 }),
735 iceberg_fetch_state_table: Some(StorageTableDesc {
736 table_id: reference.into(),
737 ..Default::default()
738 }),
739 ..Default::default()
740 }),
741 }))),
742 ..Default::default()
743 };
744 let exchange = StreamNode {
745 node_body: Some(NodeBody::Exchange(Box::new(ExchangeNode {
746 strategy: Some(DispatchStrategy {
747 r#type: DispatcherType::Hash as i32,
748 dist_key_indices: vec![0],
749 output_mapping: PbDispatchOutputMapping::simple(vec![0, 1]).into(),
750 }),
751 }))),
752 ..Default::default()
753 };
754 let fetch = StreamNode {
755 node_body: Some(NodeBody::StreamFsFetch(Box::new(StreamFsFetchNode {
756 node_inner: Some(StreamFsFetch {
757 state_table: Some(PbTable {
758 id: FETCH_TABLE.into(),
759 columns: vec![
760 column("task", DataType::Varchar),
761 column("state", DataType::Jsonb),
762 ],
763 pk: vec![PbColumnOrder {
764 column_index: 0,
765 order_type: Some(OrderType::ascending().to_protobuf()),
766 }],
767 distribution_key: vec![0],
768 stream_key: vec![0],
769 value_indices: vec![0, 1],
770 read_prefix_len_hint: 1,
771 ..Default::default()
772 }),
773 ..Default::default()
774 }),
775 }))),
776 input: vec![exchange],
777 ..Default::default()
778 };
779 StreamFragmentGraphProto {
780 fragments: HashMap::from([
781 (
782 1.into(),
783 StreamFragment {
784 fragment_id: 1.into(),
785 node: Some(list),
786 fragment_type_mask: FragmentTypeFlag::Source as u32,
787 requires_singleton: true,
788 },
789 ),
790 (
791 0.into(),
792 StreamFragment {
793 fragment_id: 0.into(),
794 node: Some(fetch),
795 fragment_type_mask: FragmentTypeFlag::FsFetch as u32,
796 requires_singleton: false,
797 },
798 ),
799 ]),
800 edges: vec![StreamFragmentEdgeProto {
801 dispatch_strategy: Some(DispatchStrategy {
802 r#type: DispatcherType::Hash as i32,
803 dist_key_indices: vec![0],
804 output_mapping: PbDispatchOutputMapping::simple(vec![0, 1]).into(),
805 }),
806 link_id: 1,
807 upstream_id: 1.into(),
808 downstream_id: 0.into(),
809 }],
810 ctx: Some(PbStreamContext::default()),
811 table_ids_cnt: 2,
812 max_parallelism: VirtualNode::COUNT_FOR_TEST as _,
813 ..Default::default()
814 }
815 }
816
817 fn job() -> StreamingJob {
818 StreamingJob::Table(
819 None,
820 PbTable {
821 id: 888.into(),
822 ..Default::default()
823 },
824 TableJobType::General,
825 )
826 }
827
828 fn fetch_table_and_reference(builder: &ActorGraphBuilder) -> (PbTable, StorageTableDesc) {
829 let mut fetch = None;
830 let mut reference = None;
831 for fragment in builder.fragment_graph.building_fragments().values() {
832 visit_fragment(fragment, |body| match body {
833 NodeBody::StreamFsFetch(node) => {
834 fetch = node.node_inner.as_ref().unwrap().state_table.clone();
835 }
836 NodeBody::Source(node) => {
837 reference = node
838 .source_inner
839 .as_ref()
840 .unwrap()
841 .iceberg_fetch_state_table
842 .clone();
843 }
844 _ => {}
845 });
846 }
847 (fetch.unwrap(), reference.unwrap())
848 }
849
850 #[tokio::test]
851 async fn test_iceberg_completion_table_is_filled_from_fetch_table() -> MetaResult<()> {
852 let env = MetaSrvEnv::for_test().await;
853 let mut fragment_graph = StreamFragmentGraph::new(&env, update_graph(FETCH_TABLE), &job())?;
854 let table_id_map: HashMap<_, _> = fragment_graph
856 .incomplete_internal_tables()
857 .into_keys()
858 .map(|id| (id, (id.as_raw_id() + 1000).into()))
859 .collect();
860 fragment_graph.refill_internal_table_ids(table_id_map);
861
862 let builder =
863 ActorGraphBuilder::new(CompleteStreamFragmentGraph::for_test(fragment_graph))?;
864 let (fetch, reference) = fetch_table_and_reference(&builder);
865 assert_eq!(reference.table_id, fetch.id);
866 assert_eq!(
867 reference,
868 TableDesc::from_pb_table(&fetch).try_to_protobuf()?
869 );
870 assert_eq!(reference.columns.len(), 2);
871 assert_eq!(reference.pk, fetch.pk);
872 assert_eq!(reference.dist_key_in_pk_indices, vec![0]);
873 assert_eq!(reference.value_indices, vec![0, 1]);
874 assert_eq!(reference.stream_key, vec![0]);
875 assert_eq!(reference.read_prefix_len_hint, 1);
876 assert_eq!(reference.vnode_count(), VirtualNode::COUNT_FOR_TEST);
877 assert_eq!(reference.maybe_vnode_count, fetch.maybe_vnode_count);
878 Ok(())
879 }
880
881 #[tokio::test]
882 async fn test_iceberg_completion_table_must_reference_fetch_table() -> MetaResult<()> {
883 let env = MetaSrvEnv::for_test().await;
884 let error = StreamFragmentGraph::new(&env, update_graph(LIST_TABLE), &job())
885 .expect_err("reference must resolve to a Fetch state table");
886 assert!(error.to_string().contains("Fetch state table"), "{error}");
887
888 let mut fragment_graph = StreamFragmentGraph::new(&env, update_graph(FETCH_TABLE), &job())?;
890 for fragment in fragment_graph.fragments.values_mut() {
891 visit_iceberg_completion_tables(fragment, |desc| desc.table_id = 4242.into());
892 }
893 let error = ActorGraphBuilder::new(CompleteStreamFragmentGraph::for_test(fragment_graph))
894 .expect_err("dangling reference must be rejected");
895 assert!(error.to_string().contains("Fetch state table"), "{error}");
896 Ok(())
897 }
898}