1use std::collections::{BTreeMap, HashMap};
16use std::num::NonZeroU64;
17use std::sync::Arc;
18
19use anyhow::anyhow;
20use async_trait::async_trait;
21use bytes::Bytes;
22use chrono_tz::Tz;
23use mysql_async::Opts;
24use mysql_async::prelude::Queryable;
25use risingwave_common::array::{Op, StreamChunk};
26use risingwave_common::catalog::Schema;
27use risingwave_common::types::DataType;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use serde_with::{DisplayFromStr, serde_as};
31use thiserror_ext::AsReport;
32use url::form_urlencoded;
33use uuid::Uuid;
34use with_options::WithOptions;
35
36use super::decouple_checkpoint_log_sink::DEFAULT_COMMIT_CHECKPOINT_INTERVAL_WITH_SINK_DECOUPLE;
37use super::doris_starrocks_connector::{
38 HeaderBuilder, InserterInner, STARROCKS_DELETE_SIGN, STARROCKS_SUCCESS_STATUS,
39 StarrocksTxnRequestBuilder,
40};
41use super::encoder::{JsonEncoder, RowEncoder};
42use super::{
43 SINK_TYPE_APPEND_ONLY, SINK_TYPE_OPTION, SINK_TYPE_UPSERT, SinkError, SinkParam,
44 SinkWriterMetrics,
45};
46use crate::enforce_secret::EnforceSecret;
47use crate::sink::decouple_checkpoint_log_sink::DecoupleCheckpointLogSinkerOf;
48use crate::sink::writer::SinkWriter;
49use crate::sink::{Result, Sink, SinkWriterParam};
50
51pub const STARROCKS_SINK: &str = "starrocks";
52const STARROCK_MYSQL_PREFER_SOCKET: &str = "false";
53const STARROCK_MYSQL_MAX_ALLOWED_PACKET: usize = 1024;
54const STARROCK_MYSQL_WAIT_TIMEOUT: usize = 28800;
55pub const fn _default_stream_load_http_timeout_ms() -> u64 {
56 30 * 1000
57}
58
59const fn default_use_https() -> bool {
60 false
61}
62
63#[serde_as]
64#[derive(Deserialize, Debug, Clone, WithOptions)]
65pub struct StarrocksCommon {
66 #[serde(rename = "starrocks.host")]
68 pub host: String,
69 #[serde(rename = "starrocks.mysqlport", alias = "starrocks.query_port")]
71 pub mysql_port: String,
72 #[serde(rename = "starrocks.httpport", alias = "starrocks.http_port")]
74 pub http_port: String,
75 #[serde(rename = "starrocks.user")]
77 pub user: String,
78 #[serde(rename = "starrocks.password")]
80 pub password: String,
81 #[serde(rename = "starrocks.database")]
83 pub database: String,
84 #[serde(rename = "starrocks.table")]
86 pub table: String,
87
88 #[serde(rename = "starrocks.use_https")]
90 #[serde(default = "default_use_https")]
91 #[serde_as(as = "DisplayFromStr")]
92 pub use_https: bool,
93}
94
95impl EnforceSecret for StarrocksCommon {
96 const ENFORCE_SECRET_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
97 "starrocks.password", "starrocks.user"
98 };
99}
100
101#[serde_as]
102#[derive(Clone, Debug, Deserialize, WithOptions)]
103pub struct StarrocksConfig {
104 #[serde(flatten)]
105 pub common: StarrocksCommon,
106
107 #[serde(
109 rename = "starrocks.stream_load.http.timeout.ms",
110 default = "_default_stream_load_http_timeout_ms"
111 )]
112 #[serde_as(as = "DisplayFromStr")]
113 #[with_option(allow_alter_on_fly)]
114 pub stream_load_http_timeout_ms: u64,
115
116 #[serde(default = "default_commit_checkpoint_interval")]
122 #[serde_as(as = "DisplayFromStr")]
123 #[with_option(allow_alter_on_fly)]
124 pub commit_checkpoint_interval: u64,
125
126 #[serde(rename = "starrocks.partial_update")]
128 pub partial_update: Option<String>,
129
130 #[serde(rename = "starrocks.max_batch_size_bytes")]
133 #[serde_as(as = "Option<DisplayFromStr>")]
134 #[with_option(allow_alter_on_fly)]
135 pub max_batch_size_bytes: Option<u64>,
136
137 pub r#type: String, #[serde(flatten)]
140 pub unknown_fields: std::collections::HashMap<String, String>,
141}
142
143crate::impl_sink_unknown_fields!(StarrocksConfig);
144
145impl EnforceSecret for StarrocksConfig {
146 fn enforce_one(prop: &str) -> crate::error::ConnectorResult<()> {
147 StarrocksCommon::enforce_one(prop)
148 }
149}
150
151fn default_commit_checkpoint_interval() -> u64 {
152 DEFAULT_COMMIT_CHECKPOINT_INTERVAL_WITH_SINK_DECOUPLE
153}
154
155impl StarrocksConfig {
156 pub fn from_btreemap(properties: BTreeMap<String, String>) -> Result<Self> {
157 let config =
158 serde_json::from_value::<StarrocksConfig>(serde_json::to_value(properties).unwrap())
159 .map_err(|e| SinkError::Config(anyhow!(e)))?;
160 if config.r#type != SINK_TYPE_APPEND_ONLY && config.r#type != SINK_TYPE_UPSERT {
161 return Err(SinkError::Config(anyhow!(
162 "`{}` must be {}, or {}",
163 SINK_TYPE_OPTION,
164 SINK_TYPE_APPEND_ONLY,
165 SINK_TYPE_UPSERT
166 )));
167 }
168 if config.commit_checkpoint_interval == 0 {
169 return Err(SinkError::Config(anyhow!(
170 "`commit_checkpoint_interval` must be greater than 0"
171 )));
172 }
173 if let Some(0) = config.max_batch_size_bytes {
174 return Err(SinkError::Config(anyhow!(
175 "`starrocks.max_batch_size_bytes` must be greater than 0"
176 )));
177 }
178 Ok(config)
179 }
180}
181
182#[derive(Debug, PartialEq, Eq)]
183struct LoadRequestSizeDecision {
184 finish_current_load: bool,
185 next_batch_size_bytes: u64,
186}
187
188fn decide_load_request_size(
189 current_batch_size_bytes: u64,
190 row_size: u64,
191 max_batch_size_bytes: u64,
192) -> Result<LoadRequestSizeDecision> {
193 if row_size > max_batch_size_bytes {
194 return Err(SinkError::Starrocks(format!(
195 "single row payload size {} bytes exceeds `starrocks.max_batch_size_bytes` limit {} bytes",
196 row_size, max_batch_size_bytes
197 )));
198 }
199
200 if current_batch_size_bytes > 0
201 && current_batch_size_bytes
202 .checked_add(row_size)
203 .is_none_or(|next_batch_size_bytes| next_batch_size_bytes > max_batch_size_bytes)
204 {
205 return Ok(LoadRequestSizeDecision {
206 finish_current_load: true,
207 next_batch_size_bytes: row_size,
208 });
209 }
210
211 Ok(LoadRequestSizeDecision {
212 finish_current_load: false,
213 next_batch_size_bytes: current_batch_size_bytes
214 .checked_add(row_size)
215 .expect("sum is checked against max_batch_size_bytes above"),
216 })
217}
218
219#[derive(Debug)]
220pub struct StarrocksSink {
221 pub config: StarrocksConfig,
222 schema: Schema,
223 pk_indices: Vec<usize>,
224 is_append_only: bool,
225}
226
227impl EnforceSecret for StarrocksSink {
228 fn enforce_secret<'a>(
229 prop_iter: impl Iterator<Item = &'a str>,
230 ) -> crate::error::ConnectorResult<()> {
231 for prop in prop_iter {
232 StarrocksConfig::enforce_one(prop)?;
233 }
234 Ok(())
235 }
236}
237
238impl StarrocksSink {
239 pub fn new(param: SinkParam, config: StarrocksConfig, schema: Schema) -> Result<Self> {
240 let pk_indices = param.downstream_pk_or_empty();
241 let is_append_only = param.sink_type.is_append_only();
242 Ok(Self {
243 config,
244 schema,
245 pk_indices,
246 is_append_only,
247 })
248 }
249}
250
251impl StarrocksSink {
252 fn starrocks_data_type_contains_any(
253 starrocks_data_type: &str,
254 expected_types: &[&str],
255 ) -> bool {
256 expected_types
257 .iter()
258 .any(|expected_type| starrocks_data_type.contains(expected_type))
259 }
260
261 fn check_column_name_and_type(
262 &self,
263 starrocks_columns_desc: HashMap<String, String>,
264 ) -> Result<()> {
265 let rw_fields_name = self.schema.fields();
266 if rw_fields_name.len() > starrocks_columns_desc.len() {
267 return Err(SinkError::Starrocks("The columns of the sink must be equal to or a superset of the target table's columns.".to_owned()));
268 }
269
270 for i in rw_fields_name {
271 let value = starrocks_columns_desc.get(&i.name).ok_or_else(|| {
272 SinkError::Starrocks(format!(
273 "Column name don't find in starrocks, risingwave is {:?} ",
274 i.name
275 ))
276 })?;
277 if !Self::check_and_correct_column_type(&i.data_type, value)? {
278 return Err(SinkError::Starrocks(format!(
279 "Column type don't match, column name is {:?}. starrocks type is {:?} risingwave type is {:?} ",
280 i.name, value, i.data_type
281 )));
282 }
283 }
284 Ok(())
285 }
286
287 fn check_and_correct_column_type(
288 rw_data_type: &DataType,
289 starrocks_data_type: &str,
290 ) -> Result<bool> {
291 match rw_data_type {
292 risingwave_common::types::DataType::Boolean => {
293 Ok(Self::starrocks_data_type_contains_any(
294 starrocks_data_type,
295 &["tinyint", "boolean"],
296 ))
297 }
298 risingwave_common::types::DataType::Int16 => {
299 Ok(starrocks_data_type.contains("smallint"))
300 }
301 risingwave_common::types::DataType::Int32 => Ok(starrocks_data_type.contains("int")),
302 risingwave_common::types::DataType::Int64 => Ok(starrocks_data_type.contains("bigint")),
303 risingwave_common::types::DataType::Float32 => {
304 Ok(starrocks_data_type.contains("float"))
305 }
306 risingwave_common::types::DataType::Float64 => {
307 Ok(starrocks_data_type.contains("double"))
308 }
309 risingwave_common::types::DataType::Decimal => {
310 Ok(starrocks_data_type.contains("decimal"))
311 }
312 risingwave_common::types::DataType::Date => Ok(starrocks_data_type.contains("date")),
313 risingwave_common::types::DataType::Varchar => {
314 Ok(starrocks_data_type.contains("varchar"))
315 }
316 risingwave_common::types::DataType::Time => Err(SinkError::Starrocks(
317 "TIME is not supported for Starrocks sink. Please convert to VARCHAR or other supported types.".to_owned(),
318 )),
319 risingwave_common::types::DataType::Timestamp => {
320 Ok(starrocks_data_type.contains("datetime"))
321 }
322 risingwave_common::types::DataType::Timestamptz => {
323 Ok(Self::starrocks_data_type_contains_any(
326 starrocks_data_type,
327 &["datetime", "varchar", "char", "string"],
328 ))
329 }
330 risingwave_common::types::DataType::Interval => Err(SinkError::Starrocks(
331 "INTERVAL is not supported for Starrocks sink. Please convert to VARCHAR or other supported types.".to_owned(),
332 )),
333 risingwave_common::types::DataType::Struct(_) => Err(SinkError::Starrocks(
334 "STRUCT is not supported for Starrocks sink.".to_owned(),
335 )),
336 risingwave_common::types::DataType::List(list) => {
337 if starrocks_data_type.contains("unknown") {
339 return Ok(true);
340 }
341 let check_result = Self::check_and_correct_column_type(list.elem(), starrocks_data_type)?;
342 Ok(check_result && starrocks_data_type.contains("array"))
343 }
344 risingwave_common::types::DataType::Bytea => Err(SinkError::Starrocks(
345 "BYTEA is not supported for Starrocks sink. Please convert to VARCHAR or other supported types.".to_owned(),
346 )),
347 risingwave_common::types::DataType::Jsonb => Ok(starrocks_data_type.contains("json")),
348 risingwave_common::types::DataType::Variant => Err(SinkError::Starrocks(
349 "VARIANT is not supported for Starrocks sink.".to_owned(),
350 )),
351 risingwave_common::types::DataType::Serial => {
352 Ok(starrocks_data_type.contains("bigint"))
353 }
354 risingwave_common::types::DataType::Int256 => Err(SinkError::Starrocks(
355 "INT256 is not supported for Starrocks sink.".to_owned(),
356 )),
357 risingwave_common::types::DataType::Map(_) => Err(SinkError::Starrocks(
358 "MAP is not supported for Starrocks sink.".to_owned(),
359 )),
360 DataType::Vector(_) => Err(SinkError::Starrocks(
361 "VECTOR is not supported for Starrocks sink.".to_owned(),
362 )),
363 }
364 }
365}
366
367impl Sink for StarrocksSink {
368 type LogSinker = DecoupleCheckpointLogSinkerOf<StarrocksSinkWriter>;
369
370 const SINK_NAME: &'static str = STARROCKS_SINK;
371
372 crate::impl_validate_sink_unknown_fields!();
373
374 async fn validate(&self) -> Result<()> {
375 if !self.is_append_only && self.pk_indices.is_empty() {
376 return Err(SinkError::Config(anyhow!(
377 "Primary key not defined for upsert starrocks sink (please define in `primary_key` field)"
378 )));
379 }
380 let mut client = StarrocksSchemaClient::new(
382 self.config.common.host.clone(),
383 self.config.common.mysql_port.clone(),
384 self.config.common.table.clone(),
385 self.config.common.database.clone(),
386 self.config.common.user.clone(),
387 self.config.common.password.clone(),
388 )
389 .await?;
390 let (read_model, pks) = client.get_pk_from_starrocks().await?;
391
392 if !self.is_append_only && read_model.ne("PRIMARY_KEYS") {
393 return Err(SinkError::Config(anyhow!(
394 "If you want to use upsert, please set the keysType of starrocks to PRIMARY_KEY"
395 )));
396 }
397
398 for (index, filed) in self.schema.fields().iter().enumerate() {
399 if self.pk_indices.contains(&index) && !pks.contains(&filed.name) {
400 return Err(SinkError::Starrocks(format!(
401 "Can't find pk {:?} in starrocks",
402 filed.name
403 )));
404 }
405 }
406
407 let starrocks_columns_desc = client.get_columns_from_starrocks().await?;
408
409 self.check_column_name_and_type(starrocks_columns_desc)?;
410 Ok(())
411 }
412
413 fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
414 StarrocksConfig::from_btreemap(config.clone())?;
415 Ok(())
416 }
417
418 async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
419 let commit_checkpoint_interval =
420 NonZeroU64::new(self.config.commit_checkpoint_interval).expect(
421 "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
422 );
423
424 let writer = StarrocksSinkWriter::new(
425 self.config.clone(),
426 self.schema.clone(),
427 self.pk_indices.clone(),
428 self.is_append_only,
429 writer_param.time_zone,
430 )?;
431
432 let metrics = SinkWriterMetrics::new(&writer_param);
433
434 Ok(DecoupleCheckpointLogSinkerOf::new(
435 writer,
436 metrics,
437 commit_checkpoint_interval,
438 ))
439 }
440}
441
442pub struct StarrocksSinkWriter {
443 #[expect(dead_code)]
444 schema: Schema,
445 #[expect(dead_code)]
446 pk_indices: Vec<usize>,
447 is_append_only: bool,
448 client: Option<StarrocksClient>,
449 txn_client: Arc<StarrocksTxnClient>,
450 row_encoder: JsonEncoder,
451 curr_txn_label: Option<String>,
452 max_batch_size_bytes: Option<u64>,
453 current_batch_size_bytes: u64,
454}
455
456impl TryFrom<SinkParam> for StarrocksSink {
457 type Error = SinkError;
458
459 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
460 let schema = param.schema();
461 let config = StarrocksConfig::from_btreemap(param.properties.clone())?;
462 StarrocksSink::new(param, config, schema)
463 }
464}
465
466impl StarrocksSinkWriter {
467 pub fn new(
468 config: StarrocksConfig,
469 schema: Schema,
470 pk_indices: Vec<usize>,
471 is_append_only: bool,
472 time_zone: Tz,
473 ) -> Result<Self> {
474 let mut field_names = schema.names_str();
475 if !is_append_only {
476 field_names.push(STARROCKS_DELETE_SIGN);
477 };
478 let field_names = field_names
481 .into_iter()
482 .map(|name| format!("`{}`", name))
483 .collect::<Vec<String>>();
484 let field_names_str = field_names
485 .iter()
486 .map(|name| name.as_str())
487 .collect::<Vec<&str>>();
488
489 let header = HeaderBuilder::new()
490 .add_common_header()
491 .set_user_password(config.common.user.clone(), config.common.password.clone())
492 .add_json_format()
493 .set_partial_update(config.partial_update.clone())
494 .set_columns_name(field_names_str)
495 .set_db(config.common.database.clone())
496 .set_table(config.common.table.clone())
497 .build();
498
499 let url = if config.common.use_https {
500 format!("https://{}:{}", config.common.host, config.common.http_port)
501 } else {
502 format!("http://{}:{}", config.common.host, config.common.http_port)
503 };
504 let txn_request_builder =
505 StarrocksTxnRequestBuilder::new(url, header, config.stream_load_http_timeout_ms)?;
506
507 Ok(Self {
508 schema: schema.clone(),
509 pk_indices,
510 is_append_only,
511 client: None,
512 txn_client: Arc::new(StarrocksTxnClient::new(txn_request_builder)),
513 row_encoder: JsonEncoder::new_with_starrocks(schema, None, time_zone),
514 curr_txn_label: None,
515 max_batch_size_bytes: config.max_batch_size_bytes,
516 current_batch_size_bytes: 0,
517 })
518 }
519
520 async fn finish_load_request(&mut self) -> Result<()> {
521 if let Some(client) = self.client.take() {
522 client.finish().await?;
523 self.current_batch_size_bytes = 0;
524 }
525 Ok(())
526 }
527
528 async fn ensure_load_request(&mut self) -> Result<()> {
529 if self.client.is_none() {
530 let txn_label = self.curr_txn_label.clone().ok_or_else(|| {
531 SinkError::Starrocks("Can't find current starrocks transaction label".to_owned())
532 })?;
533 self.client = Some(StarrocksClient::new(self.txn_client.load(txn_label).await?));
534 self.current_batch_size_bytes = 0;
535 }
536 Ok(())
537 }
538
539 async fn write_row_json(&mut self, row_json_string: String) -> Result<()> {
540 let row_size = row_json_string.len() as u64;
541 let size_decision = self
542 .max_batch_size_bytes
543 .map(|max_batch_size_bytes| {
544 decide_load_request_size(
545 self.current_batch_size_bytes,
546 row_size,
547 max_batch_size_bytes,
548 )
549 })
550 .transpose()?;
551 if size_decision
552 .as_ref()
553 .is_some_and(|decision| decision.finish_current_load)
554 {
555 self.finish_load_request().await?;
556 }
557 self.ensure_load_request().await?;
558 self.client
559 .as_mut()
560 .ok_or_else(|| SinkError::Starrocks("Can't find starrocks sink insert".to_owned()))?
561 .write(row_json_string.into())
562 .await?;
563 if let Some(size_decision) = size_decision {
564 self.current_batch_size_bytes = size_decision.next_batch_size_bytes;
565 }
566 Ok(())
567 }
568
569 async fn append_only(&mut self, chunk: StreamChunk) -> Result<()> {
570 for (op, row) in chunk.rows() {
571 if op != Op::Insert {
572 continue;
573 }
574 let row_json_string = Value::Object(self.row_encoder.encode(row)?).to_string();
575 self.write_row_json(row_json_string).await?;
576 }
577 Ok(())
578 }
579
580 async fn upsert(&mut self, chunk: StreamChunk) -> Result<()> {
581 for (op, row) in chunk.rows() {
582 match op {
583 Op::Insert => {
584 let mut row_json_value = self.row_encoder.encode(row)?;
585 row_json_value.insert(
586 STARROCKS_DELETE_SIGN.to_owned(),
587 Value::String("0".to_owned()),
588 );
589 let row_json_string = serde_json::to_string(&row_json_value).map_err(|e| {
590 SinkError::Starrocks(format!("Json serialize error: {}", e.as_report()))
591 })?;
592 self.write_row_json(row_json_string).await?;
593 }
594 Op::Delete => {
595 let mut row_json_value = self.row_encoder.encode(row)?;
596 row_json_value.insert(
597 STARROCKS_DELETE_SIGN.to_owned(),
598 Value::String("1".to_owned()),
599 );
600 let row_json_string = serde_json::to_string(&row_json_value).map_err(|e| {
601 SinkError::Starrocks(format!("Json serialize error: {}", e.as_report()))
602 })?;
603 self.write_row_json(row_json_string).await?;
604 }
605 Op::UpdateDelete => {}
606 Op::UpdateInsert => {
607 let mut row_json_value = self.row_encoder.encode(row)?;
608 row_json_value.insert(
609 STARROCKS_DELETE_SIGN.to_owned(),
610 Value::String("0".to_owned()),
611 );
612 let row_json_string = serde_json::to_string(&row_json_value).map_err(|e| {
613 SinkError::Starrocks(format!("Json serialize error: {}", e.as_report()))
614 })?;
615 self.write_row_json(row_json_string).await?;
616 }
617 }
618 }
619 Ok(())
620 }
621
622 #[inline(always)]
624 fn new_txn_label(&self) -> String {
625 format!(
626 "rw-txn-{}-{}",
627 Uuid::new_v4(),
628 chrono::Utc::now().timestamp_micros()
629 )
630 }
631
632 async fn prepare_and_commit(&self, txn_label: String) -> Result<()> {
633 tracing::debug!(?txn_label, "prepare transaction");
634 let txn_label_res = self.txn_client.prepare(txn_label.clone()).await?;
635 if txn_label != txn_label_res {
636 return Err(SinkError::Starrocks(format!(
637 "label {} returned from prepare transaction {} differs from the current one",
638 txn_label, txn_label_res
639 )));
640 }
641 tracing::debug!(?txn_label, "commit transaction");
642 let txn_label_res = self.txn_client.commit(txn_label.clone()).await?;
643 if txn_label != txn_label_res {
644 return Err(SinkError::Starrocks(format!(
645 "label {} returned from commit transaction {} differs from the current one",
646 txn_label, txn_label_res
647 )));
648 }
649 Ok(())
650 }
651}
652
653impl Drop for StarrocksSinkWriter {
654 fn drop(&mut self) {
655 if let Some(txn_label) = self.curr_txn_label.take() {
656 let txn_client = self.txn_client.clone();
657 tokio::spawn(async move {
658 if let Err(e) = txn_client.rollback(txn_label.clone()).await {
659 tracing::error!(
660 "starrocks rollback transaction error: {:?}, txn label: {}",
661 e.as_report(),
662 txn_label
663 );
664 }
665 });
666 }
667 }
668}
669
670#[async_trait]
671impl SinkWriter for StarrocksSinkWriter {
672 async fn begin_epoch(&mut self, _epoch: u64) -> Result<()> {
673 Ok(())
674 }
675
676 async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
677 if self.curr_txn_label.is_none() {
681 let txn_label = self.new_txn_label();
682 tracing::debug!(?txn_label, "begin transaction");
683 let txn_label_res = self.txn_client.begin(txn_label.clone()).await?;
684 if txn_label != txn_label_res {
685 return Err(SinkError::Starrocks(format!(
686 "label {} returned from StarRocks {} differs from generated one",
687 txn_label, txn_label_res
688 )));
689 }
690 self.curr_txn_label = Some(txn_label);
691 }
692 if self.is_append_only {
693 self.append_only(chunk).await
694 } else {
695 self.upsert(chunk).await
696 }
697 }
698
699 async fn barrier(&mut self, is_checkpoint: bool) -> Result<()> {
700 self.finish_load_request().await?;
706
707 if is_checkpoint
708 && let Some(txn_label) = self.curr_txn_label.take()
709 && let Err(err) = self.prepare_and_commit(txn_label.clone()).await
710 {
711 match self.txn_client.rollback(txn_label.clone()).await {
712 Ok(_) => tracing::warn!(
713 ?txn_label,
714 "transaction is successfully rolled back due to commit failure"
715 ),
716 Err(err) => {
717 tracing::warn!(?txn_label, error = ?err.as_report(), "Couldn't roll back transaction after commit failed")
718 }
719 }
720
721 return Err(err);
722 }
723 Ok(())
724 }
725
726 async fn abort(&mut self) -> Result<()> {
727 if let Some(txn_label) = self.curr_txn_label.take() {
728 tracing::debug!(?txn_label, "rollback transaction");
729 self.txn_client.rollback(txn_label).await?;
730 }
731 Ok(())
732 }
733}
734
735pub struct StarrocksSchemaClient {
736 table: String,
737 db: String,
738 conn: mysql_async::Conn,
739}
740
741impl StarrocksSchemaClient {
742 pub async fn new(
743 host: String,
744 port: String,
745 table: String,
746 db: String,
747 user: String,
748 password: String,
749 ) -> Result<Self> {
750 let user = form_urlencoded::byte_serialize(user.as_bytes()).collect::<String>();
753 let password = form_urlencoded::byte_serialize(password.as_bytes()).collect::<String>();
754
755 let conn_uri = format!(
756 "mysql://{}:{}@{}:{}/{}?prefer_socket={}&max_allowed_packet={}&wait_timeout={}",
757 user,
758 password,
759 host,
760 port,
761 db,
762 STARROCK_MYSQL_PREFER_SOCKET,
763 STARROCK_MYSQL_MAX_ALLOWED_PACKET,
764 STARROCK_MYSQL_WAIT_TIMEOUT
765 );
766 let pool = mysql_async::Pool::new(
767 Opts::from_url(&conn_uri)
768 .map_err(|err| SinkError::DorisStarrocksConnect(anyhow!(err)))?,
769 );
770 let conn = pool
771 .get_conn()
772 .await
773 .map_err(|err| SinkError::DorisStarrocksConnect(anyhow!(err)))?;
774
775 Ok(Self { table, db, conn })
776 }
777
778 pub async fn get_columns_from_starrocks(&mut self) -> Result<HashMap<String, String>> {
779 let query = format!(
780 "select column_name, column_type from information_schema.columns where table_name = {:?} and table_schema = {:?};",
781 self.table, self.db
782 );
783 let mut query_map: HashMap<String, String> = HashMap::default();
784 self.conn
785 .query_map(query, |(column_name, column_type)| {
786 query_map.insert(column_name, column_type)
787 })
788 .await
789 .map_err(|err| SinkError::DorisStarrocksConnect(anyhow!(err)))?;
790 Ok(query_map)
791 }
792
793 pub async fn get_pk_from_starrocks(&mut self) -> Result<(String, String)> {
794 let query = format!(
795 "select table_model, primary_key, sort_key from information_schema.tables_config where table_name = {:?} and table_schema = {:?};",
796 self.table, self.db
797 );
798 let table_mode_pk: (String, String) = self
799 .conn
800 .query_map(
801 query,
802 |(table_model, primary_key, sort_key): (String, String, String)| match table_model
803 .as_str()
804 {
805 "AGG_KEYS" => (table_model, sort_key),
809 _ => (table_model, primary_key),
810 },
811 )
812 .await
813 .map_err(|err| SinkError::DorisStarrocksConnect(anyhow!(err)))?
814 .first()
815 .ok_or_else(|| {
816 SinkError::Starrocks(format!(
817 "Can't find schema for StarRocks table {:?} in database {:?}. Please check that the table exists and the StarRocks user has write permission on the target table.",
818 self.table, self.db
819 ))
820 })?
821 .clone();
822 Ok(table_mode_pk)
823 }
824}
825
826#[derive(Debug, Serialize, Deserialize)]
827pub struct StarrocksInsertResultResponse {
828 #[serde(rename = "TxnId")]
829 pub txn_id: Option<i64>,
830 #[serde(rename = "Seq")]
831 pub seq: Option<i64>,
832 #[serde(rename = "Label")]
833 pub label: Option<String>,
834 #[serde(rename = "Status")]
835 pub status: String,
836 #[serde(rename = "Message")]
837 pub message: String,
838 #[serde(rename = "NumberTotalRows")]
839 pub number_total_rows: Option<i64>,
840 #[serde(rename = "NumberLoadedRows")]
841 pub number_loaded_rows: Option<i64>,
842 #[serde(rename = "NumberFilteredRows")]
843 pub number_filtered_rows: Option<i32>,
844 #[serde(rename = "NumberUnselectedRows")]
845 pub number_unselected_rows: Option<i32>,
846 #[serde(rename = "LoadBytes")]
847 pub load_bytes: Option<i64>,
848 #[serde(rename = "LoadTimeMs")]
849 pub load_time_ms: Option<i32>,
850 #[serde(rename = "BeginTxnTimeMs")]
851 pub begin_txn_time_ms: Option<i32>,
852 #[serde(rename = "ReadDataTimeMs")]
853 pub read_data_time_ms: Option<i32>,
854 #[serde(rename = "WriteDataTimeMs")]
855 pub write_data_time_ms: Option<i32>,
856 #[serde(rename = "CommitAndPublishTimeMs")]
857 pub commit_and_publish_time_ms: Option<i32>,
858 #[serde(rename = "StreamLoadPlanTimeMs")]
859 pub stream_load_plan_time_ms: Option<i32>,
860 #[serde(rename = "ExistingJobStatus")]
861 pub existing_job_status: Option<String>,
862 #[serde(rename = "ErrorURL")]
863 pub error_url: Option<String>,
864}
865
866pub struct StarrocksClient {
867 insert: InserterInner,
868}
869impl StarrocksClient {
870 pub fn new(insert: InserterInner) -> Self {
871 Self { insert }
872 }
873
874 pub async fn write(&mut self, data: Bytes) -> Result<()> {
875 self.insert.write(data).await?;
876 Ok(())
877 }
878
879 pub async fn finish(self) -> Result<StarrocksInsertResultResponse> {
880 let raw = self.insert.finish().await?;
881 let res: StarrocksInsertResultResponse = serde_json::from_slice(&raw)
882 .map_err(|err| SinkError::DorisStarrocksConnect(anyhow!(err)))?;
883
884 if !STARROCKS_SUCCESS_STATUS.contains(&res.status.as_str()) {
885 return Err(SinkError::DorisStarrocksConnect(anyhow::anyhow!(
886 "Insert error: {}, {}, {:?}",
887 res.status,
888 res.message,
889 res.error_url,
890 )));
891 };
892 Ok(res)
893 }
894}
895
896pub struct StarrocksTxnClient {
897 request_builder: StarrocksTxnRequestBuilder,
898}
899
900impl StarrocksTxnClient {
901 pub fn new(request_builder: StarrocksTxnRequestBuilder) -> Self {
902 Self { request_builder }
903 }
904
905 fn check_response_and_extract_label(&self, res: Bytes) -> Result<String> {
906 let res: StarrocksInsertResultResponse = serde_json::from_slice(&res)
907 .map_err(|err| SinkError::DorisStarrocksConnect(anyhow!(err)))?;
908 if !STARROCKS_SUCCESS_STATUS.contains(&res.status.as_str()) {
909 return Err(SinkError::DorisStarrocksConnect(anyhow::anyhow!(
910 "transaction error: {}, {}, {:?}",
911 res.status,
912 res.message,
913 res.error_url,
914 )));
915 }
916 res.label.ok_or_else(|| {
917 SinkError::DorisStarrocksConnect(anyhow::anyhow!("Can't get label from response"))
918 })
919 }
920
921 pub async fn begin(&self, label: String) -> Result<String> {
922 let res = self
923 .request_builder
924 .build_begin_request_sender(label)?
925 .send()
926 .await?;
927 self.check_response_and_extract_label(res)
928 }
929
930 pub async fn prepare(&self, label: String) -> Result<String> {
931 let res = self
932 .request_builder
933 .build_prepare_request_sender(label)?
934 .send()
935 .await?;
936 self.check_response_and_extract_label(res)
937 }
938
939 pub async fn commit(&self, label: String) -> Result<String> {
940 let res = self
941 .request_builder
942 .build_commit_request_sender(label)?
943 .send()
944 .await?;
945 self.check_response_and_extract_label(res)
946 }
947
948 pub async fn rollback(&self, label: String) -> Result<String> {
949 let res = self
950 .request_builder
951 .build_rollback_request_sender(label)?
952 .send()
953 .await?;
954 self.check_response_and_extract_label(res)
955 }
956
957 pub async fn load(&self, label: String) -> Result<InserterInner> {
958 self.request_builder.build_txn_inserter(label).await
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use risingwave_common::types::DataType;
965
966 use super::*;
967
968 fn is_compatible(rw_data_type: DataType, starrocks_data_type: &str) -> bool {
969 StarrocksSink::check_and_correct_column_type(&rw_data_type, starrocks_data_type).unwrap()
970 }
971
972 #[test]
973 fn test_timestamptz_compatible_starrocks_types() {
974 for starrocks_data_type in [
975 "datetime",
976 "datetime(6)",
977 "varchar(64)",
978 "char(32)",
979 "string",
980 ] {
981 assert!(
982 is_compatible(DataType::Timestamptz, starrocks_data_type),
983 "{starrocks_data_type} should be compatible with timestamptz"
984 );
985 }
986 }
987
988 #[test]
989 fn test_timestamptz_incompatible_starrocks_types() {
990 for starrocks_data_type in ["date", "int", "bigint", "json", "boolean"] {
991 assert!(
992 !is_compatible(DataType::Timestamptz, starrocks_data_type),
993 "{starrocks_data_type} should not be compatible with timestamptz"
994 );
995 }
996 }
997
998 fn base_properties() -> BTreeMap<String, String> {
999 BTreeMap::from([
1000 ("starrocks.host".to_owned(), "127.0.0.1".to_owned()),
1001 ("starrocks.mysqlport".to_owned(), "9030".to_owned()),
1002 ("starrocks.httpport".to_owned(), "8030".to_owned()),
1003 ("starrocks.user".to_owned(), "root".to_owned()),
1004 ("starrocks.password".to_owned(), "".to_owned()),
1005 ("starrocks.database".to_owned(), "demo".to_owned()),
1006 ("starrocks.table".to_owned(), "sink_table".to_owned()),
1007 ("type".to_owned(), SINK_TYPE_APPEND_ONLY.to_owned()),
1008 ])
1009 }
1010
1011 #[test]
1012 fn starrocks_max_batch_size_bytes_defaults_to_none() {
1013 let config = StarrocksConfig::from_btreemap(base_properties()).unwrap();
1014
1015 assert_eq!(config.max_batch_size_bytes, None);
1016 }
1017
1018 #[test]
1019 fn starrocks_max_batch_size_bytes_parses() {
1020 let mut properties = base_properties();
1021 properties.insert("starrocks.max_batch_size_bytes".to_owned(), "2".to_owned());
1022
1023 let config = StarrocksConfig::from_btreemap(properties).unwrap();
1024
1025 assert_eq!(config.max_batch_size_bytes, Some(2));
1026 }
1027
1028 #[test]
1029 fn starrocks_max_batch_size_bytes_rejects_zero() {
1030 let mut properties = base_properties();
1031 properties.insert("starrocks.max_batch_size_bytes".to_owned(), "0".to_owned());
1032
1033 let err = StarrocksConfig::from_btreemap(properties).unwrap_err();
1034
1035 assert!(
1036 err.to_string()
1037 .contains("`starrocks.max_batch_size_bytes` must be greater than 0")
1038 );
1039 }
1040
1041 #[test]
1042 fn starrocks_batch_size_allows_exact_limit() {
1043 assert_eq!(
1044 decide_load_request_size(3, 2, 5).unwrap(),
1045 LoadRequestSizeDecision {
1046 finish_current_load: false,
1047 next_batch_size_bytes: 5,
1048 }
1049 );
1050 }
1051
1052 #[test]
1053 fn starrocks_batch_size_rolls_over_before_exceeding_limit() {
1054 assert_eq!(
1055 decide_load_request_size(4, 2, 5).unwrap(),
1056 LoadRequestSizeDecision {
1057 finish_current_load: true,
1058 next_batch_size_bytes: 2,
1059 }
1060 );
1061 }
1062
1063 #[test]
1064 fn starrocks_batch_size_rejects_single_oversized_row() {
1065 let err = decide_load_request_size(0, 6, 5).unwrap_err();
1066
1067 assert!(err.to_string().contains(
1068 "single row payload size 6 bytes exceeds `starrocks.max_batch_size_bytes` limit 5 bytes"
1069 ));
1070 }
1071
1072 #[test]
1073 fn starrocks_batch_size_rolls_over_on_u64_overflow() {
1074 assert_eq!(
1075 decide_load_request_size(u64::MAX, 1, u64::MAX).unwrap(),
1076 LoadRequestSizeDecision {
1077 finish_current_load: true,
1078 next_batch_size_bytes: 1,
1079 }
1080 );
1081 }
1082}