Skip to main content

risingwave_frontend/optimizer/plan_node/generic/
source.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
15use std::rc::Rc;
16
17use educe::Educe;
18use risingwave_common::catalog::{ColumnCatalog, ColumnDesc, Field, Schema};
19use risingwave_common::types::DataType;
20use risingwave_common::util::sort_util::OrderType;
21use risingwave_connector::WithPropertiesExt;
22use risingwave_sqlparser::ast::AsOf;
23
24use super::super::utils::TableCatalogBuilder;
25use super::GenericPlanNode;
26use crate::TableCatalog;
27use crate::catalog::source_catalog::SourceCatalog;
28use crate::optimizer::optimizer_context::OptimizerContextRef;
29use crate::optimizer::property::{FunctionalDependencySet, StreamKind};
30
31/// In which scnario the source node is created
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33#[expect(clippy::enum_variant_names)]
34pub enum SourceNodeKind {
35    /// `CREATE TABLE` with a connector.
36    CreateTable,
37    /// `CREATE SOURCE` with a streaming job (shared source).
38    CreateSharedSource,
39    /// `CREATE MATERIALIZED VIEW` or batch scan from a source.
40    ///
41    /// Note:
42    /// - For non-shared source, `CREATE SOURCE` will not create a source node, and `CREATE MATERIALIZE VIEW` will create a `StreamSource`.
43    /// - For shared source, `CREATE MATERIALIZE VIEW` will create `StreamSourceScan` instead of `StreamSource`.
44    CreateMViewOrBatch,
45}
46
47/// [`Source`] returns contents of a table or other equivalent object
48#[derive(Debug, Clone, Educe)]
49#[educe(PartialEq, Eq, Hash)]
50pub struct Source {
51    /// If there is an external stream source, `catalog` will be `Some`. Otherwise, it is `None`.
52    pub catalog: Option<Rc<SourceCatalog>>,
53
54    // NOTE: Here we store `column_catalog` and `row_id_index`
55    // because they are needed when `catalog` is None.
56    // When `catalog` is Some, they are the same as these fields in `catalog`.
57    pub column_catalog: Vec<ColumnCatalog>,
58    pub row_id_index: Option<usize>,
59
60    pub kind: SourceNodeKind,
61
62    #[educe(PartialEq(ignore))]
63    #[educe(Hash(ignore))]
64    pub ctx: OptimizerContextRef,
65
66    pub as_of: Option<AsOf>,
67}
68
69impl GenericPlanNode for Source {
70    fn schema(&self) -> Schema {
71        let fields = self
72            .column_catalog
73            .iter()
74            .map(|c| (&c.column_desc).into())
75            .collect();
76        Schema { fields }
77    }
78
79    fn stream_key(&self) -> Option<Vec<usize>> {
80        // FIXME: output col idx is not set. But iceberg source can prune cols.
81        // XXX: there's a RISINGWAVE_ICEBERG_ROW_ID. Should we use it?
82        if let Some(idx) = self.row_id_index {
83            Some(vec![idx])
84        } else if let Some(catalog) = &self.catalog {
85            catalog
86                .pk_col_ids
87                .iter()
88                .map(|id| {
89                    self.column_catalog
90                        .iter()
91                        .position(|c| c.column_id() == *id)
92                })
93                .collect::<Option<Vec<_>>>()
94        } else {
95            None
96        }
97    }
98
99    fn ctx(&self) -> OptimizerContextRef {
100        self.ctx.clone()
101    }
102
103    fn functional_dependency(&self) -> FunctionalDependencySet {
104        let pk_indices = self.stream_key();
105        match pk_indices {
106            Some(pk_indices) => {
107                debug_assert!(
108                    pk_indices
109                        .iter()
110                        .all(|idx| *idx < self.column_catalog.len())
111                );
112                FunctionalDependencySet::with_key(self.column_catalog.len(), &pk_indices)
113            }
114            None => FunctionalDependencySet::new(self.column_catalog.len()),
115        }
116    }
117}
118
119impl Source {
120    pub fn stream_kind(&self) -> StreamKind {
121        if let Some(catalog) = &self.catalog {
122            if catalog.is_iceberg_update_source() {
123                StreamKind::Retract
124            } else if catalog.append_only {
125                StreamKind::AppendOnly
126            } else {
127                // Always treat source as upsert, as we either don't parse the old record for `Update`, or we don't
128                // trust the old record from external source.
129                StreamKind::Upsert
130            }
131        } else {
132            // `Source` acts only as a barrier receiver. There's no data at all.
133            StreamKind::AppendOnly
134        }
135    }
136
137    /// The output is [`risingwave_connector::source::filesystem::FsPageItem`] / [`iceberg::scan::FileScanTask`]
138    pub fn file_list_node(core: Self) -> Self {
139        let column_catalog = if core.is_iceberg_update_source() {
140            // Task assignments, in the layout of the Fetch state table.
141            Self::iceberg_update_task_fields()
142                .iter()
143                .enumerate()
144                .map(|(id, field)| ColumnCatalog {
145                    column_desc: ColumnDesc::from_field_with_column_id(field, id as i32),
146                    is_hidden: false,
147                })
148                .collect()
149        } else if core.is_iceberg_connector() {
150            vec![
151                ColumnCatalog {
152                    column_desc: ColumnDesc::from_field_with_column_id(
153                        &Field {
154                            name: "file_path".to_owned(),
155                            data_type: DataType::Varchar,
156                        },
157                        0,
158                    ),
159                    is_hidden: false,
160                },
161                ColumnCatalog {
162                    column_desc: ColumnDesc::from_field_with_column_id(
163                        &Field {
164                            name: "file_scan_task".to_owned(),
165                            data_type: DataType::Jsonb,
166                        },
167                        1,
168                    ),
169                    is_hidden: false,
170                },
171            ]
172        } else if core.is_batch_connector() {
173            vec![
174                ColumnCatalog {
175                    column_desc: ColumnDesc::from_field_with_column_id(
176                        &Field {
177                            name: "batch_task_id".to_owned(),
178                            data_type: DataType::Varchar,
179                        },
180                        0,
181                    ),
182                    is_hidden: false,
183                },
184                ColumnCatalog {
185                    column_desc: ColumnDesc::from_field_with_column_id(
186                        &Field {
187                            name: "batch_task_info".to_owned(),
188                            data_type: DataType::Jsonb,
189                        },
190                        1,
191                    ),
192                    is_hidden: false,
193                },
194            ]
195        } else if core.is_new_fs_connector() {
196            vec![
197                ColumnCatalog {
198                    column_desc: ColumnDesc::from_field_with_column_id(
199                        &Field {
200                            name: "filename".to_owned(),
201                            data_type: DataType::Varchar,
202                        },
203                        0,
204                    ),
205                    is_hidden: false,
206                },
207                // This columns seems unused.
208                ColumnCatalog {
209                    column_desc: ColumnDesc::from_field_with_column_id(
210                        &Field {
211                            name: "last_edit_time".to_owned(),
212                            data_type: DataType::Timestamptz,
213                        },
214                        1,
215                    ),
216                    is_hidden: false,
217                },
218                ColumnCatalog {
219                    column_desc: ColumnDesc::from_field_with_column_id(
220                        &Field {
221                            name: "file_size".to_owned(),
222                            data_type: DataType::Int64,
223                        },
224                        2,
225                    ),
226                    is_hidden: false,
227                },
228            ]
229        } else {
230            unreachable!()
231        };
232        Self {
233            column_catalog,
234            row_id_index: None,
235            ..core
236        }
237    }
238
239    pub fn is_new_fs_connector(&self) -> bool {
240        self.catalog
241            .as_ref()
242            .is_some_and(|catalog| catalog.with_properties.is_new_fs_connector())
243    }
244
245    pub fn is_iceberg_connector(&self) -> bool {
246        self.catalog
247            .as_ref()
248            .is_some_and(|catalog| catalog.with_properties.is_iceberg_connector())
249    }
250
251    pub fn is_iceberg_update_source(&self) -> bool {
252        self.catalog
253            .as_ref()
254            .is_some_and(|catalog| catalog.is_iceberg_update_source())
255    }
256
257    pub fn is_kafka_connector(&self) -> bool {
258        self.catalog
259            .as_ref()
260            .is_some_and(|catalog| catalog.with_properties.is_kafka_connector())
261    }
262
263    pub fn is_batch_connector(&self) -> bool {
264        self.catalog
265            .as_ref()
266            .is_some_and(|catalog| catalog.with_properties.is_batch_connector())
267    }
268
269    pub fn requires_singleton(&self) -> bool {
270        self.is_iceberg_connector() || self.is_batch_connector()
271    }
272
273    /// Currently, only iceberg source supports time travel.
274    pub fn support_time_travel(&self) -> bool {
275        self.is_iceberg_connector()
276    }
277
278    pub fn exclude_iceberg_hidden_columns(mut self) -> Self {
279        if self.is_iceberg_update_source() {
280            return self;
281        }
282        let Some(catalog) = &mut self.catalog else {
283            return self;
284        };
285        if catalog.info.is_shared() {
286            // for shared source, we should produce all columns
287            return self;
288        }
289        if self.kind != SourceNodeKind::CreateMViewOrBatch {
290            return self;
291        }
292
293        let prune = |col: &ColumnCatalog| col.is_hidden() && !col.is_row_id_column();
294
295        // minus the number of hidden columns before row_id_index.
296        self.row_id_index = self.row_id_index.map(|idx| {
297            let mut cnt = 0;
298            for col in self.column_catalog.iter().take(idx + 1) {
299                if prune(col) {
300                    cnt += 1;
301                }
302            }
303            idx - cnt
304        });
305        self.column_catalog.retain(|c| !prune(c));
306        self
307    }
308
309    /// The columns in stream/batch source node indicate the actual columns it will produce,
310    /// instead of the columns defined in source catalog. The difference is generated columns.
311    pub fn exclude_generated_columns(mut self) -> (Self, Option<usize>) {
312        let original_row_id_index = self.row_id_index;
313        // minus the number of generated columns before row_id_index.
314        self.row_id_index = original_row_id_index.map(|idx| {
315            let mut cnt = 0;
316            for col in self.column_catalog.iter().take(idx + 1) {
317                if col.is_generated() {
318                    cnt += 1;
319                }
320            }
321            idx - cnt
322        });
323        self.column_catalog.retain(|c| !c.is_generated());
324        (self, original_row_id_index)
325    }
326
327    /// Source's state table is `partition_id -> offset_info`.
328    /// Its schema is irrelevant to the data's schema.
329    ///
330    /// ## Notes on the distribution of the state table (`is_distributed`)
331    ///
332    /// Source executors are always distributed, but their state tables are special.
333    ///
334    /// ### `StreamSourceExecutor`: singleton (only one vnode)
335    ///
336    /// Its states are not sharded by consistent hash.
337    ///
338    /// Each actor accesses (point get) some partitions (a.k.a splits).
339    /// They are assigned by `SourceManager` in meta,
340    /// instead of `vnode` computed from the `partition_id`.
341    ///
342    /// ### `StreamFsFetch`: distributed by `partition_id`
343    ///
344    /// Each actor accesses (range scan) splits according to the `vnode`
345    /// computed from `partition_id`.
346    /// This is a normal distributed table.
347    pub fn infer_internal_table_catalog(is_distributed: bool) -> TableCatalog {
348        let mut builder = TableCatalogBuilder::default();
349
350        let key = Field {
351            data_type: DataType::Varchar,
352            name: "partition_id".to_owned(),
353        };
354        let value = Field {
355            data_type: DataType::Jsonb,
356            name: "offset_info".to_owned(),
357        };
358
359        let ordered_col_idx = builder.add_column(&key);
360        builder.add_column(&value);
361        builder.add_order_column(ordered_col_idx, OrderType::ascending());
362
363        builder.build(
364            if is_distributed {
365                vec![ordered_col_idx]
366            } else {
367                vec![]
368            },
369            1,
370        )
371    }
372
373    fn iceberg_update_task_fields() -> [Field; 3] {
374        [
375            Field {
376                data_type: DataType::Int64,
377                name: "route".to_owned(),
378            },
379            Field {
380                data_type: DataType::Int64,
381                name: "seq".to_owned(),
382            },
383            Field {
384                data_type: DataType::Jsonb,
385                name: "task".to_owned(),
386            },
387        ]
388    }
389
390    /// The Fetch state table of an Iceberg `streaming_updates` source. Tasks are keyed by
391    /// `(route, seq)` and distributed by `route`, so a Fetch actor scans the tasks of a route in
392    /// dispatch order. The List-to-Fetch exchange must hash the same `route` column.
393    pub fn infer_iceberg_update_fetch_table_catalog() -> TableCatalog {
394        let mut builder = TableCatalogBuilder::default();
395        let [route, seq, task] = Self::iceberg_update_task_fields();
396        let route = builder.add_column(&route);
397        let seq = builder.add_column(&seq);
398        builder.add_column(&task);
399        builder.add_order_column(route, OrderType::ascending());
400        builder.add_order_column(seq, OrderType::ascending());
401        builder.build(vec![route], 2)
402    }
403
404    pub fn clone_with_column_catalog(&self, column_catalog: Vec<ColumnCatalog>) -> Self {
405        let row_id_index = column_catalog.iter().position(|c| c.is_row_id_column());
406        Self {
407            catalog: self.catalog.clone(),
408            column_catalog,
409            row_id_index,
410            kind: self.kind.clone(),
411            ctx: self.ctx.clone(),
412            as_of: self.as_of.clone(),
413        }
414    }
415}