1use anyhow::Context;
16use pretty_xmlish::{Pretty, XmlNode};
17use risingwave_common::catalog::{Field, Schema};
18use risingwave_common::types::DataType;
19use risingwave_common::util::sort_util::OrderType;
20use risingwave_connector::sink::catalog::desc::SinkDesc;
21use risingwave_pb::stream_plan::compaction_resolver_node::PkColumn;
22use risingwave_pb::stream_plan::stream_node::{NodeBody, PbStreamKind};
23use risingwave_pb::stream_plan::{
24 CompactionResolverNode, DispatchStrategy, DispatcherType, ExchangeNode,
25 IcebergWithPkIndexWriterNode, PbDispatchOutputMapping, PbStreamNode,
26};
27
28use super::stream::prelude::*;
29use crate::TableCatalog;
30use crate::optimizer::plan_node::expr_visitable::ExprVisitable;
31use crate::optimizer::plan_node::utils::{Distill, TableCatalogBuilder, childless_record};
32use crate::optimizer::plan_node::{
33 ExprRewritable, PlanBase, PlanTreeNodeUnary, Stream, StreamExchange, StreamNode,
34 StreamPlanRef as PlanRef,
35};
36use crate::optimizer::property::{
37 Distribution, FunctionalDependencySet, MonotonicityMap, WatermarkColumns,
38};
39use crate::scheduler::SchedulerResult;
40use crate::stream_fragmenter::BuildFragmentGraphState;
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct StreamIcebergWithPkIndexWriter {
49 pub base: PlanBase<Stream>,
50 pub input: PlanRef,
51 pub sink_desc: SinkDesc,
52 pub pk_index_table: TableCatalog,
53}
54
55impl StreamIcebergWithPkIndexWriter {
56 pub fn from_stream_sink(sink: &super::StreamSink) -> Result<Self> {
57 let output_schema = output_schema();
58 let fd_set = FunctionalDependencySet::new(output_schema.len());
59 let dist = match sink.distribution() {
60 Distribution::Single => Distribution::Single,
61 _ => Distribution::SomeShard,
62 };
63 let base = PlanBase::new_stream(
64 sink.ctx(),
65 output_schema,
66 sink.stream_key().map(|v| v.to_vec()),
67 fd_set,
68 dist,
69 StreamKind::AppendOnly,
70 sink.emit_on_window_close(),
71 WatermarkColumns::new(),
72 MonotonicityMap::new(),
73 );
74 let pk_index_table = build_iceberg_pk_state_table(sink.sink_desc())?;
75 Ok(Self {
76 base,
77 input: sink.input(),
78 sink_desc: sink.sink_desc().clone(),
79 pk_index_table,
80 })
81 }
82}
83
84fn output_schema() -> Schema {
85 Schema::new(vec![
86 Field::with_name(DataType::Varchar, "file_path"),
87 Field::with_name(DataType::Int64, "position"),
88 ])
89}
90
91fn resolver_output_schema(sink_desc: &SinkDesc) -> Result<Schema> {
93 let downstream_pk = sink_desc
94 .downstream_pk
95 .as_deref()
96 .context("Missing downstream PK in Iceberg sink desc")?;
97 let mut fields: Vec<Field> = downstream_pk
98 .iter()
99 .map(|&idx| Field::from(&sink_desc.columns[idx].column_desc))
100 .collect();
101 fields.push(Field::with_name(DataType::Varchar, "file_path"));
102 fields.push(Field::with_name(DataType::Int64, "position"));
103 Ok(Schema::new(fields))
104}
105
106fn build_iceberg_pk_state_table(sink_desc: &SinkDesc) -> Result<TableCatalog> {
107 let mut builder = TableCatalogBuilder::default();
108
109 let downstream_pk = sink_desc
110 .downstream_pk
111 .as_deref()
112 .context("Missing downstream PK in Iceberg sink desc")?;
113 for &idx in downstream_pk {
114 builder.add_column(&Field::from(&sink_desc.columns[idx].column_desc));
115 }
116 builder.add_column(&Field::with_name(DataType::Varchar, "file_path"));
117 builder.add_column(&Field::with_name(DataType::Int64, "position"));
118
119 for idx in 0..downstream_pk.len() {
120 builder.add_order_column(idx, OrderType::ascending());
121 }
122
123 let res = builder.build((0..downstream_pk.len()).collect(), downstream_pk.len());
124 Ok(res)
125}
126
127impl Distill for StreamIcebergWithPkIndexWriter {
128 fn distill<'a>(&self) -> XmlNode<'a> {
129 let column_names = self
130 .sink_desc
131 .columns
132 .iter()
133 .map(|col| col.name_with_hidden().to_string())
134 .map(Pretty::from)
135 .collect();
136 let column_names = Pretty::Array(column_names);
137 let mut vec = Vec::with_capacity(2);
138 vec.push(("columns", column_names));
139 if let Some(pk) = &self.sink_desc.downstream_pk {
140 let column_names = pk
141 .iter()
142 .map(|&idx| self.sink_desc.columns[idx].name_with_hidden().to_string())
143 .map(Pretty::from)
144 .collect();
145 let column_names = Pretty::Array(column_names);
146 vec.push(("downstream_pk", column_names));
147 }
148
149 childless_record("StreamIcebergWithPkIndexWriter", vec)
150 }
151}
152
153impl PlanTreeNodeUnary<Stream> for StreamIcebergWithPkIndexWriter {
154 fn input(&self) -> PlanRef {
155 self.input.clone()
156 }
157
158 fn clone_with_input(&self, input: PlanRef) -> Self {
159 Self {
160 base: self.base.clone(),
161 input,
162 sink_desc: self.sink_desc.clone(),
163 pk_index_table: self.pk_index_table.clone(),
164 }
165 }
166}
167
168impl_plan_tree_node_for_unary! { Stream, StreamIcebergWithPkIndexWriter }
169
170impl StreamNode for StreamIcebergWithPkIndexWriter {
171 fn to_stream_prost_body(&self, _state: &mut BuildFragmentGraphState) -> NodeBody {
172 unreachable!(
173 "iceberg pk-index writer cannot be converted into a prost body -- call \
174 `adhoc_to_stream_prost` instead, since it declares a compaction resolver input."
175 )
176 }
177}
178
179impl StreamIcebergWithPkIndexWriter {
180 pub fn adhoc_to_stream_prost(
184 &self,
185 state: &mut BuildFragmentGraphState,
186 ) -> SchedulerResult<PbStreamNode> {
187 let pk_index_table = self
188 .pk_index_table
189 .clone()
190 .with_id(state.gen_table_id_wrapped())
191 .to_internal_table_prost();
192 let resolver_fields = resolver_output_schema(&self.sink_desc)
193 .context("build compaction resolver output schema")?
194 .to_prost();
195 let downstream_pk = self
196 .sink_desc
197 .downstream_pk
198 .as_ref()
199 .expect("validated when building the resolver schema");
200 let pk_columns: Vec<PkColumn> = downstream_pk
201 .iter()
202 .map(|&idx| PkColumn {
203 data_file_index: idx as u32,
204 column_desc: Some(self.sink_desc.columns[idx].column_desc.to_protobuf()),
205 })
206 .collect();
207 let resolver_stream_key: Vec<u32> = (0..pk_columns.len() as u32).collect();
210
211 let left_input = if self.input.as_stream_exchange().is_some() {
214 self.input.to_stream_prost(state)?
215 } else {
216 let exchange: PlanRef = StreamExchange::new_no_shuffle(self.input.clone()).into();
217 exchange.to_stream_prost(state)?
218 };
219 let right_dispatcher = match self.distribution() {
220 Distribution::Single => DispatcherType::Simple,
221 _ => DispatcherType::Hash,
222 };
223 let resolver = PbStreamNode {
224 node_body: Some(NodeBody::CompactionResolver(Box::new(
225 CompactionResolverNode {
226 sink_id: self.sink_desc.id,
227 properties: self.sink_desc.properties.clone(),
228 secret_refs: self.sink_desc.secret_refs.clone(),
229 pk_columns,
230 },
231 ))),
232 identity: "CompactionResolver".into(),
233 operator_id: state.gen_operator_id(),
234 stream_key: resolver_stream_key.clone(),
235 fields: resolver_fields.clone(),
236 stream_kind: PbStreamKind::AppendOnly as i32,
237 ..Default::default()
238 };
239 let right_input = PbStreamNode {
240 node_body: Some(NodeBody::Exchange(Box::new(ExchangeNode {
241 strategy: Some(DispatchStrategy {
242 r#type: right_dispatcher as i32,
243 dist_key_indices: match right_dispatcher {
244 DispatcherType::Hash => resolver_stream_key.clone(),
245 DispatcherType::Simple => vec![],
246 DispatcherType::Unspecified
247 | DispatcherType::Broadcast
248 | DispatcherType::NoShuffle => unreachable!(),
249 },
250 output_mapping: Some(PbDispatchOutputMapping::identical(resolver_fields.len())),
251 }),
252 }))),
253 input: vec![resolver],
254 identity: "IcebergCompactionResolverExchange".into(),
255 operator_id: state.gen_operator_id(),
256 stream_key: resolver_stream_key,
257 fields: resolver_fields,
258 stream_kind: PbStreamKind::AppendOnly as i32,
259 };
260
261 Ok(PbStreamNode {
262 node_body: Some(NodeBody::IcebergWithPkIndexWriter(Box::new(
263 IcebergWithPkIndexWriterNode {
264 sink_desc: Some(self.sink_desc.to_proto()),
265 pk_index_table: Some(pk_index_table),
266 },
267 ))),
268 input: vec![left_input, right_input],
269 identity: self.distill_to_string(),
270 operator_id: self.id().to_stream_node_operator_id(),
271 stream_key: self
272 .stream_key()
273 .unwrap_or_default()
274 .iter()
275 .map(|x| *x as u32)
276 .collect(),
277 fields: self.schema().to_prost(),
278 stream_kind: self.stream_kind().to_protobuf() as i32,
279 })
280 }
281}
282
283impl ExprRewritable<Stream> for StreamIcebergWithPkIndexWriter {}
284
285impl ExprVisitable for StreamIcebergWithPkIndexWriter {}
286
287#[cfg(test)]
288mod tests {
289 use std::collections::BTreeMap;
290
291 use risingwave_common::catalog::{
292 ColumnCatalog, ColumnDesc, ColumnId, CreateType, DEFAULT_SUPER_USER_ID, StreamJobStatus,
293 };
294 use risingwave_common::util::sort_util::ColumnOrder;
295 use risingwave_connector::sink::catalog::{SinkId, SinkType};
296
297 use super::*;
298
299 fn test_sink_desc() -> SinkDesc {
300 SinkDesc {
301 id: SinkId::placeholder(),
302 name: "s".to_owned(),
303 definition: "".to_owned(),
304 columns: vec![
305 ColumnCatalog::visible(ColumnDesc::named("id", ColumnId::new(0), DataType::Int32)),
306 ColumnCatalog::visible(ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32)),
307 ColumnCatalog::hidden(ColumnDesc::named(
308 "_row_id",
309 ColumnId::new(2),
310 DataType::Serial,
311 )),
312 ],
313 plan_pk: vec![ColumnOrder::new(2, OrderType::ascending())],
314 downstream_pk: Some(vec![1]),
315 distribution_key: vec![1],
316 properties: BTreeMap::new(),
317 secret_refs: BTreeMap::new(),
318 sink_type: SinkType::Upsert,
319 ignore_delete: false,
320 format_desc: None,
321 db_name: "dev".to_owned(),
322 sink_from_name: "t".to_owned(),
323 target_table: None,
324 extra_partition_col_idx: None,
325 create_type: CreateType::Foreground,
326 is_exactly_once: None,
327 auto_refresh_schema_from_table: None,
328 }
329 }
330
331 #[test]
332 fn test_build_iceberg_pk_state_table_uses_downstream_pk_columns() {
333 let table = build_iceberg_pk_state_table(&test_sink_desc()).unwrap();
334
335 assert_eq!(table.columns()[0].name(), "v1");
336 assert_eq!(table.columns()[1].name(), "file_path");
337 assert_eq!(table.columns()[2].name(), "position");
338 assert_eq!(table.pk().len(), 1);
339 assert_eq!(table.pk()[0].column_index, 0);
340 assert_eq!(table.distribution_key(), &[0]);
341 assert_eq!(table.read_prefix_len_hint, 1);
342 assert_eq!(table.owner, DEFAULT_SUPER_USER_ID);
343 assert_eq!(table.stream_job_status, StreamJobStatus::Creating);
344 }
345
346 #[test]
347 fn test_build_iceberg_pk_state_table_with_multi_column_pk() {
348 let mut desc = test_sink_desc();
351 desc.columns.push(ColumnCatalog::visible(ColumnDesc::named(
352 "order_id",
353 ColumnId::new(3),
354 DataType::Int64,
355 )));
356 desc.columns.push(ColumnCatalog::visible(ColumnDesc::named(
357 "shard_id",
358 ColumnId::new(4),
359 DataType::Int64,
360 )));
361 desc.downstream_pk = Some(vec![1, 3, 4]); let table = build_iceberg_pk_state_table(&desc).unwrap();
364
365 let names: Vec<_> = table
366 .columns()
367 .iter()
368 .map(|c| c.name().to_owned())
369 .collect();
370 assert_eq!(
371 names,
372 vec!["v1", "order_id", "shard_id", "file_path", "position"]
373 );
374 assert_eq!(table.pk().len(), 3);
375 assert_eq!(table.distribution_key(), &[0, 1, 2]);
376 assert_eq!(table.read_prefix_len_hint, 3);
377 }
378
379 #[test]
380 fn test_build_iceberg_pk_state_table_with_visible_extra_only() {
381 let mut desc = test_sink_desc();
383 desc.columns.push(ColumnCatalog::visible(ColumnDesc::named(
384 "order_id",
385 ColumnId::new(3),
386 DataType::Int64,
387 )));
388 desc.downstream_pk = Some(vec![1, 3]); let table = build_iceberg_pk_state_table(&desc).unwrap();
391
392 let names: Vec<_> = table
393 .columns()
394 .iter()
395 .map(|c| c.name().to_owned())
396 .collect();
397 assert_eq!(names, vec!["v1", "order_id", "file_path", "position"]);
398 assert_eq!(table.pk().len(), 2);
399 assert_eq!(table.distribution_key(), &[0, 1]);
400 assert_eq!(table.read_prefix_len_hint, 2);
401 }
402}