Skip to main content

risingwave_frontend/optimizer/plan_node/
stream_cdc_table_scan.rs

1// Copyright 2023 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 itertools::Itertools;
16use pretty_xmlish::{Pretty, XmlNode};
17use risingwave_common::catalog::{ColumnCatalog, Field};
18use risingwave_common::types::DataType;
19use risingwave_common::util::sort_util::OrderType;
20use risingwave_connector::source::cdc::normalize_simple_postgres_quoted_table_name;
21use risingwave_pb::stream_plan::PbStreamNode;
22use risingwave_pb::stream_plan::stream_node::{PbNodeBody, PbStreamKind};
23
24use super::stream::prelude::*;
25use super::utils::{Distill, childless_record};
26use super::{ExprRewritable, PlanBase, StreamNode, StreamPlanRef as PlanRef, generic};
27use crate::catalog::ColumnId;
28use crate::expr::{Expr, ExprImpl, ExprRewriter, ExprType, ExprVisitor, FunctionCall, InputRef};
29use crate::optimizer::plan_node::expr_visitable::ExprVisitable;
30use crate::optimizer::plan_node::utils::{IndicesDisplay, TableCatalogBuilder};
31use crate::optimizer::property::{Distribution, DistributionDisplay};
32use crate::scheduler::SchedulerResult;
33use crate::stream_fragmenter::BuildFragmentGraphState;
34use crate::{Explain, TableCatalog};
35
36/// `StreamCdcTableScan` is a virtual plan node to represent a stream cdc table scan.
37/// It will be converted to cdc backfill + merge node (for upstream source)
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct StreamCdcTableScan {
40    pub base: PlanBase<Stream>,
41    core: generic::CdcScan,
42}
43
44impl StreamCdcTableScan {
45    pub fn new(core: generic::CdcScan) -> Self {
46        let distribution = Distribution::SomeShard;
47        let base = PlanBase::new_stream_with_core(
48            &core,
49            distribution,
50            // Debezium table scans emit upsert semantics: updates contain only the new value,
51            // and deletes may contain only primary-key columns. Declaring this correctly lets
52            // downstream materialization add conflict handling before stateful consumption.
53            StreamKind::Upsert,
54            false,
55            core.watermark_columns(),
56            core.columns_monotonicity(),
57        );
58        Self { base, core }
59    }
60
61    pub fn table_name(&self) -> &str {
62        &self.core.table_name
63    }
64
65    pub fn core(&self) -> &generic::CdcScan {
66        &self.core
67    }
68
69    /// Build catalog for cdc backfill state.
70    ///
71    /// For non-parallelized cdc backfill:
72    /// Right now we only persist whether the backfill is finished and the corresponding cdc offset
73    /// schema: | `split_id` | `pk...` | `backfill_finished` | `row_count` | `cdc_offset` |
74    ///
75    /// For parallelized cdc backfill:
76    /// schema: | `split_id` | `backfill_finished` | `row_count` | `cdc_offset_low` | `cdc_offset_high` |
77    pub fn build_backfill_state_catalog(
78        &self,
79        state: &mut BuildFragmentGraphState,
80        is_parallelized_backfill: bool,
81    ) -> TableCatalog {
82        if is_parallelized_backfill {
83            let mut catalog_builder = TableCatalogBuilder::default();
84            // Use `split_id` as primary key in state table.
85            catalog_builder.add_column(&Field::with_name(DataType::Int64, "split_id"));
86            catalog_builder.add_order_column(0, OrderType::ascending());
87            catalog_builder.add_column(&Field::with_name(DataType::Boolean, "backfill_finished"));
88            // `row_count` column, the number of rows read from snapshot
89            catalog_builder.add_column(&Field::with_name(DataType::Int64, "row_count"));
90            catalog_builder.add_column(&Field::with_name(DataType::Jsonb, "cdc_offset_low"));
91            catalog_builder.add_column(&Field::with_name(DataType::Jsonb, "cdc_offset_high"));
92            catalog_builder
93                .build(vec![], 1)
94                .with_id(state.gen_table_id_wrapped())
95        } else {
96            let mut catalog_builder = TableCatalogBuilder::default();
97            let upstream_schema = &self.core.get_table_columns();
98
99            // Use `split_id` as primary key in state table.
100            // Currently we only support single split for cdc backfill.
101            catalog_builder.add_column(&Field::with_name(DataType::Varchar, "split_id"));
102            catalog_builder.add_order_column(0, OrderType::ascending());
103
104            // pk columns
105            for col_order in self.core.primary_key() {
106                let col = &upstream_schema[col_order.column_index];
107                catalog_builder.add_column(&Field::from(col));
108            }
109
110            catalog_builder.add_column(&Field::with_name(DataType::Boolean, "backfill_finished"));
111
112            // `row_count` column, the number of rows read from snapshot
113            catalog_builder.add_column(&Field::with_name(DataType::Int64, "row_count"));
114
115            // The offset is only for observability, not for recovery right now
116            catalog_builder.add_column(&Field::with_name(DataType::Jsonb, "cdc_offset"));
117
118            // leave dist key empty, since the cdc backfill executor is singleton
119            catalog_builder
120                .build(vec![], 1)
121                .with_id(state.gen_table_id_wrapped())
122        }
123    }
124}
125
126impl_plan_tree_node_for_leaf! { Stream, StreamCdcTableScan }
127
128impl Distill for StreamCdcTableScan {
129    fn distill<'a>(&self) -> XmlNode<'a> {
130        let verbose = self.base.ctx().is_explain_verbose();
131        let mut vec = Vec::with_capacity(4);
132        vec.push(("table", Pretty::from(self.core.table_name.clone())));
133        vec.push(("columns", self.core.columns_pretty(verbose)));
134
135        if verbose {
136            let pk = IndicesDisplay {
137                indices: self.stream_key().unwrap_or_default(),
138                schema: self.base.schema(),
139            };
140            vec.push(("pk", pk.distill()));
141            let dist = Pretty::display(&DistributionDisplay {
142                distribution: self.distribution(),
143                input_schema: self.base.schema(),
144            });
145            vec.push(("dist", dist));
146        }
147
148        childless_record("StreamCdcTableScan", vec)
149    }
150}
151
152impl StreamNode for StreamCdcTableScan {
153    fn to_stream_prost_body(&self, _state: &mut BuildFragmentGraphState) -> PbNodeBody {
154        unreachable!(
155            "stream scan cannot be converted into a prost body -- call `adhoc_to_stream_prost` instead."
156        )
157    }
158}
159
160impl StreamCdcTableScan {
161    /// plan: merge -> filter -> exchange(simple) -> `stream_scan`
162    pub fn adhoc_to_stream_prost(
163        &self,
164        state: &mut BuildFragmentGraphState,
165    ) -> SchedulerResult<PbStreamNode> {
166        use risingwave_pb::stream_plan::*;
167
168        let stream_key = self
169            .stream_key()
170            .unwrap_or_else(|| {
171                panic!(
172                    "should always have a stream key in the stream plan but not, sub plan: {}",
173                    PlanRef::from(self.clone()).explain_to_string()
174                )
175            })
176            .iter()
177            .map(|x| *x as u32)
178            .collect_vec();
179
180        // The schema of the shared cdc source upstream is different from snapshot.
181        let cdc_source_schema = ColumnCatalog::debezium_cdc_source_cols()
182            .into_iter()
183            .map(|c| Field::from(c.column_desc).to_prost())
184            .collect_vec();
185
186        let catalog = self
187            .build_backfill_state_catalog(state, self.core.options.is_parallelized_backfill())
188            .to_internal_table_prost();
189
190        // We need to pass the id of upstream source job here
191        let upstream_source_id = self.core.cdc_table_desc.source_id;
192
193        // filter upstream source chunk by the value of `_rw_table_name` column
194        let filter_expr =
195            Self::build_cdc_filter_expr(self.core.cdc_table_desc.external_table_name.as_str());
196
197        let filter_operator_id = self.core.ctx.next_plan_node_id();
198        // The filter node receive chunks in `(payload, _rw_offset, _rw_table_name)` schema
199        let filter_stream_node = StreamNode {
200            operator_id: filter_operator_id.to_stream_node_operator_id(),
201            input: vec![
202                // The merge node body will be filled by the `ActorBuilder` on the meta service.
203                PbStreamNode {
204                    node_body: Some(PbNodeBody::Merge(Default::default())),
205                    identity: "Upstream".into(),
206                    fields: cdc_source_schema.clone(),
207                    stream_key: vec![], // not used
208                    ..Default::default()
209                },
210            ],
211            stream_key: vec![], // not used
212            stream_kind: PbStreamKind::AppendOnly as _,
213            identity: "StreamCdcFilter".to_owned(),
214            fields: cdc_source_schema.clone(),
215            node_body: Some(PbNodeBody::CdcFilter(Box::new(CdcFilterNode {
216                search_condition: Some(filter_expr.to_expr_proto()),
217                upstream_source_id,
218            }))),
219        };
220
221        let exchange_operator_id = self.core.ctx.next_plan_node_id();
222        let strategy = if self.core.options.is_parallelized_backfill() {
223            DispatchStrategy {
224                r#type: DispatcherType::Broadcast as _,
225                dist_key_indices: vec![],
226                output_mapping: PbDispatchOutputMapping::identical(cdc_source_schema.len()).into(),
227            }
228        } else {
229            DispatchStrategy {
230                r#type: DispatcherType::Simple as _,
231                dist_key_indices: vec![], // simple exchange doesn't need dist key
232                output_mapping: PbDispatchOutputMapping::identical(cdc_source_schema.len()).into(),
233            }
234        };
235        // Add a simple exchange node between filter and stream scan
236        let exchange_stream_node = StreamNode {
237            operator_id: exchange_operator_id.to_stream_node_operator_id(),
238            input: vec![filter_stream_node],
239            stream_key: vec![], // not used
240            stream_kind: PbStreamKind::AppendOnly as _,
241            identity: "Exchange".to_owned(),
242            fields: cdc_source_schema,
243            node_body: Some(PbNodeBody::Exchange(Box::new(ExchangeNode {
244                strategy: Some(strategy),
245            }))),
246        };
247
248        // The required columns from the external table
249        let upstream_column_ids = self
250            .core
251            .output_and_pk_column_ids()
252            .iter()
253            .map(ColumnId::get_id)
254            .collect_vec();
255
256        let output_indices = self
257            .core
258            .output_column_ids()
259            .iter()
260            .map(|i| {
261                upstream_column_ids
262                    .iter()
263                    .position(|&x| x == i.get_id())
264                    .unwrap() as u32
265            })
266            .collect_vec();
267
268        tracing::debug!(
269            output_column_ids=?self.core.output_column_ids(),
270            ?upstream_column_ids,
271            ?output_indices,
272            "stream cdc table scan output indices"
273        );
274
275        let options = self.core.options.to_proto();
276        let stream_scan_body = PbNodeBody::StreamCdcScan(Box::new(StreamCdcScanNode {
277            table_id: upstream_source_id.as_cdc_table_id(),
278            upstream_column_ids,
279            output_indices,
280            // The table desc used by backfill executor
281            state_table: Some(catalog),
282            cdc_table_desc: Some(self.core.cdc_table_desc.to_protobuf()),
283            rate_limit: self.base.ctx().overwrite_options().backfill_rate_limit,
284            disable_backfill: options.disable_backfill,
285            options: Some(options),
286        }));
287
288        // plan: merge -> filter -> exchange(simple) -> stream_scan
289        Ok(PbStreamNode {
290            fields: self.schema().to_prost(),
291            input: vec![exchange_stream_node],
292            node_body: Some(stream_scan_body),
293            stream_key,
294            operator_id: self.base.id().to_stream_node_operator_id(),
295            identity: self.distill_to_string(),
296            stream_kind: self.stream_kind().to_protobuf() as i32,
297        })
298    }
299
300    // The filter node receive input chunks in `(payload, _rw_offset, _rw_table_name)` schema
301    pub fn build_cdc_filter_expr(cdc_table_name: &str) -> ExprImpl {
302        let table_name_ref: ExprImpl = InputRef::new(2, DataType::Varchar).into();
303        let mut filter_expr = build_cdc_table_name_eq_expr(table_name_ref.clone(), cdc_table_name);
304
305        if let Some(compat_table_name) = normalize_simple_postgres_quoted_table_name(cdc_table_name)
306        {
307            // The left branch keeps matching the catalog/user-facing table name stored in
308            // `ExternalTableDesc`, e.g. `public."TableName"`. The right branch matches the
309            // Debezium runtime routing key carried in `_rw_table_name`, e.g. `public.TableName`.
310            // See `DbzChangeEventConsumer` for deriving the runtime table name from Debezium
311            // topics, and `DebeziumCdcMeta::extract_table_name` for how `_rw_table_name` is
312            // populated from that runtime name.
313            filter_expr = FunctionCall::new(
314                ExprType::Or,
315                vec![
316                    filter_expr,
317                    build_cdc_table_name_eq_expr(table_name_ref, &compat_table_name),
318                ],
319            )
320            .unwrap()
321            .into();
322        }
323
324        filter_expr
325    }
326}
327
328fn build_cdc_table_name_eq_expr(table_name_ref: ExprImpl, cdc_table_name: &str) -> ExprImpl {
329    FunctionCall::new(
330        ExprType::Equal,
331        vec![
332            table_name_ref,
333            ExprImpl::literal_varchar(cdc_table_name.into()),
334        ],
335    )
336    .unwrap()
337    .into()
338}
339
340impl ExprRewritable<Stream> for StreamCdcTableScan {
341    fn has_rewritable_expr(&self) -> bool {
342        true
343    }
344
345    fn rewrite_exprs(&self, r: &mut dyn ExprRewriter) -> PlanRef {
346        let core = self.core.clone();
347        core.rewrite_exprs(r);
348        Self::new(core).into()
349    }
350}
351
352impl ExprVisitable for StreamCdcTableScan {
353    fn visit_exprs(&self, v: &mut dyn ExprVisitor) {
354        self.core.visit_exprs(v);
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use std::str::FromStr;
361
362    use risingwave_common::row::OwnedRow;
363    use risingwave_common::types::{JsonbVal, ScalarImpl};
364
365    use super::*;
366
367    #[tokio::test]
368    async fn test_cdc_filter_expr() {
369        let t1_json = JsonbVal::from_str(r#"{ "before": null, "after": { "v": 111, "v2": 222.2 }, "source": { "version": "2.2.0.Alpha3", "connector": "mysql", "name": "dbserver1", "ts_ms": 1678428689000, "snapshot": "false", "db": "inventory", "sequence": null, "table": "t1", "server_id": 223344, "gtid": null, "file": "mysql-bin.000003", "pos": 774, "row": 0, "thread": 8, "query": null }, "op": "c", "ts_ms": 1678428689389, "transaction": null }"#).unwrap();
370        let t2_json = JsonbVal::from_str(r#"{ "before": null, "after": { "v": 333, "v2": 666.6 }, "source": { "version": "2.2.0.Alpha3", "connector": "mysql", "name": "dbserver1", "ts_ms": 1678428689000, "snapshot": "false", "db": "inventory", "sequence": null, "table": "t2", "server_id": 223344, "gtid": null, "file": "mysql-bin.000003", "pos": 884, "row": 0, "thread": 8, "query": null }, "op": "c", "ts_ms": 1678428689389, "transaction": null }"#).unwrap();
371
372        // NOTES: transaction metadata column expects to be filtered out before going to cdc filter
373        let trx_json = JsonbVal::from_str(r#"{"data_collections": null, "event_count": null, "id": "35319:3962662584", "status": "BEGIN", "ts_ms": 1704263537068}"#).unwrap();
374        let row1 = OwnedRow::new(vec![
375            Some(t1_json.into()),
376            Some(r#"{"file": "1.binlog", "pos": 100}"#.into()),
377            Some("public.t2".into()),
378        ]);
379        let row2 = OwnedRow::new(vec![
380            Some(t2_json.into()),
381            Some(r#"{"file": "2.binlog", "pos": 100}"#.into()),
382            Some("abs.t2".into()),
383        ]);
384
385        let row3 = OwnedRow::new(vec![
386            Some(trx_json.into()),
387            Some(r#"{"file": "3.binlog", "pos": 100}"#.into()),
388            Some("public.t2".into()),
389        ]);
390
391        let filter_expr = StreamCdcTableScan::build_cdc_filter_expr("public.t2");
392        assert_eq!(
393            filter_expr.eval_row(&row1).await.unwrap(),
394            Some(ScalarImpl::Bool(true))
395        );
396        assert_eq!(
397            filter_expr.eval_row(&row2).await.unwrap(),
398            Some(ScalarImpl::Bool(false))
399        );
400        assert_eq!(
401            filter_expr.eval_row(&row3).await.unwrap(),
402            Some(ScalarImpl::Bool(true))
403        )
404    }
405
406    #[tokio::test]
407    async fn test_cdc_filter_expr_postgres_quoted_table_name_compat() {
408        let row_with_unquoted_schema_table =
409            OwnedRow::new(vec![None, None, Some("public.QuotedNoteE2e".into())]);
410        let row_with_other_table = OwnedRow::new(vec![None, None, Some("public.other".into())]);
411
412        let filter_expr = StreamCdcTableScan::build_cdc_filter_expr(r#"public."QuotedNoteE2e""#);
413        assert_eq!(
414            filter_expr
415                .eval_row(&row_with_unquoted_schema_table)
416                .await
417                .unwrap(),
418            Some(ScalarImpl::Bool(true))
419        );
420        assert_eq!(
421            filter_expr.eval_row(&row_with_other_table).await.unwrap(),
422            Some(ScalarImpl::Bool(false))
423        );
424    }
425}