1use core::num::NonZeroU64;
16use std::collections::{BTreeMap, HashMap};
17use std::sync::Arc;
18
19use anyhow::{Context, anyhow};
20use async_trait::async_trait;
21use deltalake::DeltaTable;
22use deltalake::aws::constants::{
23 AWS_ACCESS_KEY_ID, AWS_ALLOW_HTTP, AWS_ENDPOINT_URL, AWS_REGION, AWS_S3_ALLOW_UNSAFE_RENAME,
24 AWS_SECRET_ACCESS_KEY,
25};
26use deltalake::kernel::transaction::{CommitBuilder, CommitProperties};
27use deltalake::kernel::{Action, Add, DataType as DeltaLakeDataType, PrimitiveType, Transaction};
28use deltalake::protocol::{DeltaOperation, SaveMode};
29use deltalake::writer::{DeltaWriter, RecordBatchWriter};
30use phf::{Set, phf_set};
31use risingwave_common::array::StreamChunk;
32use risingwave_common::array::arrow::DeltaLakeConvert;
33use risingwave_common::bail;
34use risingwave_common::catalog::Schema;
35use risingwave_common::types::DataType;
36use risingwave_common::util::iter_util::ZipEqDebug;
37use risingwave_pb::connector_service::SinkMetadata;
38use risingwave_pb::connector_service::sink_metadata::Metadata::Serialized;
39use risingwave_pb::connector_service::sink_metadata::SerializedMetadata;
40use serde::{Deserialize, Serialize};
41use serde_with::{DisplayFromStr, serde_as};
42use tokio::sync::mpsc::UnboundedSender;
43use url::Url;
44use with_options::WithOptions;
45
46use crate::connector_common::{AwsAuthProps, IcebergSinkCompactionUpdate};
47use crate::enforce_secret::{EnforceSecret, EnforceSecretError};
48use crate::sink::coordinate::CoordinatedLogSinker;
49use crate::sink::decouple_checkpoint_log_sink::default_commit_checkpoint_interval;
50use crate::sink::writer::SinkWriter;
51use crate::sink::{
52 Result, SINK_TYPE_APPEND_ONLY, SINK_USER_FORCE_APPEND_ONLY_OPTION,
53 SinglePhaseCommitCoordinator, Sink, SinkCommitCoordinator, SinkError, SinkParam,
54 SinkWriterParam, TwoPhaseCommitCoordinator,
55};
56
57pub const DEFAULT_REGION: &str = "us-east-1";
58pub const GCS_SERVICE_ACCOUNT: &str = "service_account_key";
59
60pub const DELTALAKE_SINK: &str = "deltalake";
61
62#[serde_as]
63#[derive(Deserialize, Debug, Clone, WithOptions)]
64pub struct DeltaLakeCommon {
65 #[serde(rename = "location")]
66 pub location: String,
67 #[serde(flatten)]
68 pub aws_auth_props: AwsAuthProps,
69
70 #[serde(rename = "gcs.service.account")]
71 pub gcs_service_account: Option<String>,
72 #[serde(default = "default_commit_checkpoint_interval")]
74 #[serde_as(as = "DisplayFromStr")]
75 #[with_option(allow_alter_on_fly)]
76 pub commit_checkpoint_interval: u64,
77}
78
79impl EnforceSecret for DeltaLakeCommon {
80 const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
81 "gcs.service.account",
82 };
83
84 fn enforce_one(prop: &str) -> crate::error::ConnectorResult<()> {
85 AwsAuthProps::enforce_one(prop)?;
86 if Self::ENFORCE_SECRET_PROPERTIES.contains(prop) {
87 return Err(EnforceSecretError {
88 key: prop.to_owned(),
89 }
90 .into());
91 }
92
93 Ok(())
94 }
95}
96
97impl DeltaLakeCommon {
98 pub async fn create_deltalake_client(&self) -> Result<DeltaTable> {
99 let table = match Self::get_table_url(&self.location)? {
100 DeltaTableUrl::S3(s3_path) => {
101 let storage_options = self.build_delta_lake_config_for_aws().await?;
102 deltalake::aws::register_handlers(None);
103 let url = Url::parse(&s3_path).map_err(|e| SinkError::DeltaLake(anyhow!(e)))?;
104 deltalake::open_table_with_storage_options(url, storage_options).await?
105 }
106 DeltaTableUrl::Local(local_path) => {
107 let url = Url::parse(&format!("file://{}", local_path))
108 .map_err(|e| SinkError::DeltaLake(anyhow!(e)))?;
109 deltalake::open_table(url).await?
110 }
111 DeltaTableUrl::Gcs(gcs_path) => {
112 let mut storage_options = HashMap::new();
113 storage_options.insert(
114 GCS_SERVICE_ACCOUNT.to_owned(),
115 self.gcs_service_account.clone().ok_or_else(|| {
116 SinkError::Config(anyhow!(
117 "gcs.service.account is required with Google Cloud Storage (GCS)"
118 ))
119 })?,
120 );
121 deltalake::gcp::register_handlers(None);
122 let url = Url::parse(&gcs_path).map_err(|e| SinkError::DeltaLake(anyhow!(e)))?;
123 deltalake::open_table_with_storage_options(url, storage_options).await?
124 }
125 };
126 Ok(table)
127 }
128
129 fn get_table_url(path: &str) -> Result<DeltaTableUrl> {
130 if path.starts_with("s3://") || path.starts_with("s3a://") {
131 Ok(DeltaTableUrl::S3(path.to_owned()))
132 } else if path.starts_with("gs://") {
133 Ok(DeltaTableUrl::Gcs(path.to_owned()))
134 } else if let Some(path) = path.strip_prefix("file://") {
135 Ok(DeltaTableUrl::Local(path.to_owned()))
136 } else {
137 Err(SinkError::DeltaLake(anyhow!(
138 "path should start with 's3://','s3a://'(s3) ,gs://(gcs) or file://(local)"
139 )))
140 }
141 }
142
143 async fn build_delta_lake_config_for_aws(&self) -> Result<HashMap<String, String>> {
144 let mut storage_options = HashMap::new();
145 storage_options.insert(AWS_ALLOW_HTTP.to_owned(), "true".to_owned());
146 storage_options.insert(AWS_S3_ALLOW_UNSAFE_RENAME.to_owned(), "true".to_owned());
147 let sdk_config = self.aws_auth_props.build_config().await?;
148 let credentials = sdk_config
149 .credentials_provider()
150 .ok_or_else(|| {
151 SinkError::Config(anyhow!(
152 "s3.access.key and s3.secret.key is required with aws s3"
153 ))
154 })?
155 .as_ref()
156 .provide_credentials()
157 .await
158 .map_err(|e| SinkError::Config(e.into()))?;
159 let region = sdk_config.region();
160 let endpoint = sdk_config.endpoint_url();
161 storage_options.insert(
162 AWS_ACCESS_KEY_ID.to_owned(),
163 credentials.access_key_id().to_owned(),
164 );
165 storage_options.insert(
166 AWS_SECRET_ACCESS_KEY.to_owned(),
167 credentials.secret_access_key().to_owned(),
168 );
169 if endpoint.is_none() && region.is_none() {
170 return Err(SinkError::Config(anyhow!(
171 "s3.endpoint and s3.region need to be filled with at least one"
172 )));
173 }
174 storage_options.insert(
175 AWS_REGION.to_owned(),
176 region
177 .map(|r| r.as_ref().to_owned())
178 .unwrap_or_else(|| DEFAULT_REGION.to_owned()),
179 );
180 if let Some(s3_endpoint) = endpoint {
181 storage_options.insert(AWS_ENDPOINT_URL.to_owned(), s3_endpoint.to_owned());
182 }
183 Ok(storage_options)
184 }
185}
186
187enum DeltaTableUrl {
188 S3(String),
189 Local(String),
190 Gcs(String),
191}
192
193#[serde_as]
194#[derive(Clone, Debug, Deserialize, WithOptions)]
195pub struct DeltaLakeConfig {
196 #[serde(flatten)]
197 pub common: DeltaLakeCommon,
198
199 pub r#type: String,
200
201 #[serde_as(as = "Option<DisplayFromStr>")]
203 pub is_exactly_once: Option<bool>,
204
205 #[serde(flatten)]
206 pub unknown_fields: std::collections::HashMap<String, String>,
207}
208
209crate::impl_sink_unknown_fields!(DeltaLakeConfig);
210
211impl EnforceSecret for DeltaLakeConfig {
212 fn enforce_one(prop: &str) -> crate::error::ConnectorResult<()> {
213 DeltaLakeCommon::enforce_one(prop)
214 }
215}
216
217impl DeltaLakeConfig {
218 pub fn from_btreemap(properties: BTreeMap<String, String>) -> Result<Self> {
219 let mut config = serde_json::from_value::<DeltaLakeConfig>(
220 serde_json::to_value(properties).map_err(|e| SinkError::DeltaLake(e.into()))?,
221 )
222 .map_err(|e| SinkError::Config(anyhow!(e)))?;
223 for key in [
224 "aws.credentials.access_key_id",
225 "aws.credentials.role.arn",
226 "aws.credentials.role.external_id",
227 "aws.credentials.secret_access_key",
228 "aws.credentials.session_token",
229 "aws.endpoint_url",
230 "aws.msk.signer_timeout_sec",
231 "aws.profile",
232 "aws.region",
233 "access_key",
234 "arn",
235 "commit_checkpoint_interval",
236 "endpoint",
237 "endpoint_url",
238 "external_id",
239 "gcs.service.account",
240 "is_exactly_once",
241 "location",
242 "profile",
243 "region",
244 "s3.access.key",
245 "s3.endpoint",
246 "s3.region",
247 "s3.secret.key",
248 "secret_key",
249 "session_token",
250 "type",
251 ] {
252 config.unknown_fields.remove(key);
253 }
254 Ok(config)
255 }
256}
257
258#[derive(Debug)]
259pub struct DeltaLakeSink {
260 pub config: DeltaLakeConfig,
261 param: SinkParam,
262}
263
264impl EnforceSecret for DeltaLakeSink {
265 fn enforce_secret<'a>(
266 prop_iter: impl Iterator<Item = &'a str>,
267 ) -> crate::error::ConnectorResult<()> {
268 for prop in prop_iter {
269 DeltaLakeCommon::enforce_one(prop)?;
270 }
271 Ok(())
272 }
273}
274
275impl DeltaLakeSink {
276 pub fn new(config: DeltaLakeConfig, param: SinkParam) -> Result<Self> {
277 Ok(Self { config, param })
278 }
279}
280
281fn check_field_type(rw_data_type: &DataType, dl_data_type: &DeltaLakeDataType) -> Result<bool> {
282 let result = match rw_data_type {
283 DataType::Boolean => {
284 matches!(
285 dl_data_type,
286 DeltaLakeDataType::Primitive(PrimitiveType::Boolean)
287 )
288 }
289 DataType::Int16 => {
290 matches!(
291 dl_data_type,
292 DeltaLakeDataType::Primitive(PrimitiveType::Short)
293 )
294 }
295 DataType::Int32 => {
296 matches!(
297 dl_data_type,
298 DeltaLakeDataType::Primitive(PrimitiveType::Integer)
299 )
300 }
301 DataType::Int64 => {
302 matches!(
303 dl_data_type,
304 DeltaLakeDataType::Primitive(PrimitiveType::Long)
305 )
306 }
307 DataType::Float32 => {
308 matches!(
309 dl_data_type,
310 DeltaLakeDataType::Primitive(PrimitiveType::Float)
311 )
312 }
313 DataType::Float64 => {
314 matches!(
315 dl_data_type,
316 DeltaLakeDataType::Primitive(PrimitiveType::Double)
317 )
318 }
319 DataType::Decimal => {
320 matches!(
321 dl_data_type,
322 DeltaLakeDataType::Primitive(PrimitiveType::Decimal(_))
323 )
324 }
325 DataType::Date => {
326 matches!(
327 dl_data_type,
328 DeltaLakeDataType::Primitive(PrimitiveType::Date)
329 )
330 }
331 DataType::Varchar => {
332 matches!(
333 dl_data_type,
334 DeltaLakeDataType::Primitive(PrimitiveType::String)
335 )
336 }
337 DataType::Timestamptz => {
338 matches!(
339 dl_data_type,
340 DeltaLakeDataType::Primitive(PrimitiveType::Timestamp)
341 )
342 }
343 DataType::Struct(rw_struct) => {
344 if let DeltaLakeDataType::Struct(dl_struct) = dl_data_type {
345 let mut result = true;
346 for ((rw_name, rw_type), dl_field) in
347 rw_struct.iter().zip_eq_debug(dl_struct.fields())
348 {
349 result = check_field_type(rw_type, dl_field.data_type())?
350 && result
351 && rw_name.eq(dl_field.name());
352 }
353 result
354 } else {
355 false
356 }
357 }
358 DataType::List(rw_list) => {
359 if let DeltaLakeDataType::Array(dl_list) = dl_data_type {
360 check_field_type(rw_list.elem(), dl_list.element_type())?
361 } else {
362 false
363 }
364 }
365 _ => {
366 return Err(SinkError::DeltaLake(anyhow!(
367 "Type {:?} is not supported for DeltaLake sink.",
368 rw_data_type.to_owned()
369 )));
370 }
371 };
372 Ok(result)
373}
374
375impl Sink for DeltaLakeSink {
376 type LogSinker = CoordinatedLogSinker<DeltaLakeSinkWriter>;
377
378 const SINK_NAME: &'static str = DELTALAKE_SINK;
379
380 crate::impl_validate_sink_unknown_fields!();
381
382 fn is_exactly_once(properties: &BTreeMap<String, String>) -> Result<bool> {
383 let Some(value) = properties.get("is_exactly_once") else {
384 return Ok(false);
385 };
386 value.parse::<bool>().map_err(|_| {
387 SinkError::Config(anyhow!(
388 "invalid value for `is_exactly_once`: expected `true` or `false`, got `{value}`"
389 ))
390 })
391 }
392
393 async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
394 let inner = DeltaLakeSinkWriter::new(
395 self.config.clone(),
396 self.param.schema().clone(),
397 self.param.downstream_pk_or_empty(),
398 )
399 .await?;
400
401 let commit_checkpoint_interval =
402 NonZeroU64::new(self.config.common.commit_checkpoint_interval).expect(
403 "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
404 );
405
406 let writer = CoordinatedLogSinker::new(
407 &writer_param,
408 self.param.clone(),
409 inner,
410 commit_checkpoint_interval,
411 )
412 .await?;
413
414 Ok(writer)
415 }
416
417 fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
418 DeltaLakeConfig::from_btreemap(config.clone())?;
419 Ok(())
420 }
421
422 async fn validate(&self) -> Result<()> {
423 if self.config.r#type != SINK_TYPE_APPEND_ONLY
424 && self.config.r#type != SINK_USER_FORCE_APPEND_ONLY_OPTION
425 {
426 return Err(SinkError::Config(anyhow!(
427 "only append-only delta lake sink is supported",
428 )));
429 }
430 let table = self.config.common.create_deltalake_client().await?;
431 let snapshot = table.snapshot()?;
432 let delta_schema = snapshot.schema();
433 let deltalake_fields: HashMap<&String, &DeltaLakeDataType> = delta_schema
434 .fields()
435 .map(|f| (f.name(), f.data_type()))
436 .collect();
437 if deltalake_fields.len() != self.param.schema().fields().len() {
438 return Err(SinkError::DeltaLake(anyhow!(
439 "Columns mismatch. RisingWave schema has {} fields, DeltaLake schema has {} fields",
440 self.param.schema().fields().len(),
441 deltalake_fields.len()
442 )));
443 }
444 for field in self.param.schema().fields() {
445 if !deltalake_fields.contains_key(&field.name) {
446 return Err(SinkError::DeltaLake(anyhow!(
447 "column {} not found in deltalake table",
448 field.name
449 )));
450 }
451 let deltalake_field_type = deltalake_fields.get(&field.name).ok_or_else(|| {
452 SinkError::DeltaLake(anyhow!("cannot find field type for {}", field.name))
453 })?;
454 if !check_field_type(&field.data_type, deltalake_field_type)? {
455 return Err(SinkError::DeltaLake(anyhow!(
456 "column '{}' type mismatch: deltalake type is {:?}, RisingWave type is {:?}",
457 field.name,
458 deltalake_field_type,
459 field.data_type
460 )));
461 }
462 }
463 if self.config.common.commit_checkpoint_interval == 0 {
464 return Err(SinkError::Config(anyhow!(
465 "`commit_checkpoint_interval` must be greater than 0"
466 )));
467 }
468 Ok(())
469 }
470
471 fn is_coordinated_sink(&self) -> bool {
472 true
473 }
474
475 async fn new_coordinator(
476 &self,
477 _iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
478 ) -> Result<SinkCommitCoordinator> {
479 let coordinator = DeltaLakeSinkCommitter {
480 table: self.config.common.create_deltalake_client().await?,
481 app_id: format!("risingwave-deltalake-{}", self.param.sink_id),
482 exactly_once: Self::is_exactly_once(&self.param.properties)?,
483 };
484 if coordinator.exactly_once {
485 Ok(SinkCommitCoordinator::TwoPhase(Box::new(coordinator)))
486 } else {
487 Ok(SinkCommitCoordinator::SinglePhase(Box::new(coordinator)))
488 }
489 }
490}
491
492impl TryFrom<SinkParam> for DeltaLakeSink {
493 type Error = SinkError;
494
495 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
496 let config = DeltaLakeConfig::from_btreemap(param.properties.clone())?;
497 DeltaLakeSink::new(config, param)
498 }
499}
500
501pub struct DeltaLakeSinkWriter {
502 pub config: DeltaLakeConfig,
503 #[expect(dead_code)]
504 schema: Schema,
505 #[expect(dead_code)]
506 pk_indices: Vec<usize>,
507 writer: RecordBatchWriter,
508 dl_schema: Arc<deltalake::arrow::datatypes::Schema>,
509 #[expect(dead_code)]
510 dl_table: DeltaTable,
511}
512
513impl DeltaLakeSinkWriter {
514 pub async fn new(
515 config: DeltaLakeConfig,
516 schema: Schema,
517 pk_indices: Vec<usize>,
518 ) -> Result<Self> {
519 let dl_table = config.common.create_deltalake_client().await?;
520 let writer = RecordBatchWriter::for_table(&dl_table)?;
521 let dl_schema = writer.arrow_schema();
522
523 Ok(Self {
524 config,
525 schema,
526 pk_indices,
527 writer,
528 dl_schema,
529 dl_table,
530 })
531 }
532
533 async fn write(&mut self, chunk: StreamChunk) -> Result<()> {
534 let a = DeltaLakeConvert
535 .to_record_batch(self.dl_schema.clone(), &chunk)
536 .context("convert record batch error")
537 .map_err(SinkError::DeltaLake)?;
538 self.writer.write(a).await?;
539 Ok(())
540 }
541}
542
543#[async_trait]
544impl SinkWriter for DeltaLakeSinkWriter {
545 type CommitMetadata = Option<SinkMetadata>;
546
547 async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
548 self.write(chunk).await
549 }
550
551 async fn begin_epoch(&mut self, _epoch: u64) -> Result<()> {
552 Ok(())
553 }
554
555 async fn abort(&mut self) -> Result<()> {
556 Ok(())
557 }
558
559 async fn barrier(&mut self, is_checkpoint: bool) -> Result<Option<SinkMetadata>> {
560 if !is_checkpoint {
561 return Ok(None);
562 }
563
564 let adds = self.writer.flush().await?;
565 Ok(Some(SinkMetadata::try_from(&DeltaLakeWriteResult {
566 adds,
567 })?))
568 }
569}
570
571pub struct DeltaLakeSinkCommitter {
572 table: DeltaTable,
573 app_id: String,
574 exactly_once: bool,
575}
576
577impl DeltaLakeSinkCommitter {
578 fn collect_write_adds(metadata: &[SinkMetadata]) -> Result<Vec<Add>> {
579 Ok(metadata
580 .iter()
581 .map(DeltaLakeWriteResult::try_from)
582 .collect::<Result<Vec<_>>>()?
583 .into_iter()
584 .flat_map(|v| v.adds.into_iter())
585 .collect())
586 }
587
588 fn delta_txn_version(epoch: u64) -> Result<i64> {
589 Ok(i64::try_from(epoch).context("delta lake epoch exceeds i64 range")?)
590 }
591
592 async fn commit_actions(
593 &mut self,
594 epoch: u64,
595 adds: Vec<Add>,
596 txn_identity: Option<&DeltaLakeTxnIdentity>,
597 ) -> Result<()> {
598 if adds.is_empty() {
599 return Ok(());
600 }
601
602 if let Some(txn_identity) = txn_identity
603 && self.is_txn_committed(txn_identity).await?
604 {
605 tracing::info!(
606 "DeltaLake epoch {epoch} already committed for app id {}, txn version {}, skip committing again.",
607 txn_identity.app_id,
608 txn_identity.version
609 );
610 return Ok(());
611 }
612
613 let write_adds = adds.into_iter().map(Action::Add).collect();
614
615 let partition_cols = self
616 .table
617 .snapshot()?
618 .metadata()
619 .partition_columns()
620 .to_vec();
621 let partition_by = if !partition_cols.is_empty() {
622 Some(partition_cols)
623 } else {
624 None
625 };
626 let operation = DeltaOperation::Write {
627 mode: SaveMode::Append,
628 partition_by,
629 predicate: None,
630 };
631 let commit_builder = if let Some(txn_identity) = txn_identity {
632 let commit_builder: CommitBuilder = CommitProperties::default()
633 .with_application_transaction(Transaction::new(
634 &txn_identity.app_id,
635 txn_identity.version,
636 ))
637 .into();
638 commit_builder
639 } else {
640 CommitBuilder::default()
641 };
642 let version = commit_builder
643 .with_actions(write_adds)
644 .build(
645 Some(self.table.snapshot()?),
646 self.table.log_store().clone(),
647 operation,
648 )
649 .await?
650 .version();
651 self.table.update_state().await?;
652 tracing::debug!(
653 "Succeeded to commit to DeltaLake table in epoch {epoch}, version {version}."
654 );
655 Ok(())
656 }
657
658 async fn is_txn_committed(&mut self, txn_identity: &DeltaLakeTxnIdentity) -> Result<bool> {
659 self.table.update_state().await?;
660 let log_store = self.table.log_store();
661 let committed_version = self
662 .table
663 .snapshot()?
664 .transaction_version(log_store.as_ref(), &txn_identity.app_id)
665 .await?;
666 Ok(committed_version.is_some_and(|version| version >= txn_identity.version))
667 }
668}
669
670#[async_trait::async_trait]
671impl SinglePhaseCommitCoordinator for DeltaLakeSinkCommitter {
672 async fn init(&mut self) -> Result<()> {
673 tracing::info!("DeltaLake commit coordinator inited.");
674 Ok(())
675 }
676
677 async fn commit_data(&mut self, epoch: u64, metadata: Vec<SinkMetadata>) -> Result<()> {
678 tracing::debug!("Starting DeltaLake commit in epoch {epoch}.");
679
680 let adds = Self::collect_write_adds(&metadata)?;
681 self.commit_actions(epoch, adds, None).await
682 }
683}
684
685#[async_trait::async_trait]
686impl TwoPhaseCommitCoordinator for DeltaLakeSinkCommitter {
687 async fn init(&mut self) -> Result<()> {
688 tracing::info!("DeltaLake commit coordinator inited.");
689 Ok(())
690 }
691
692 async fn pre_commit(
693 &mut self,
694 epoch: u64,
695 metadata: Vec<SinkMetadata>,
696 _schema_change: Option<risingwave_pb::stream_plan::PbSinkSchemaChange>,
697 ) -> Result<Option<Vec<u8>>> {
698 tracing::debug!("Starting DeltaLake pre commit in epoch {epoch}.");
699
700 let adds = Self::collect_write_adds(&metadata)?;
701 if adds.is_empty() {
702 return Ok(None);
703 }
704
705 let txn_identity = DeltaLakeTxnIdentity {
706 app_id: self.app_id.clone(),
707 version: Self::delta_txn_version(epoch)?,
708 };
709 Ok(Some(
710 DeltaLakePreCommitMetadata { adds, txn_identity }.try_into_bytes()?,
711 ))
712 }
713
714 async fn commit_data(&mut self, epoch: u64, commit_metadata: Vec<u8>) -> Result<()> {
715 tracing::debug!("Starting DeltaLake exactly-once commit in epoch {epoch}.");
716
717 if commit_metadata.is_empty() {
718 return Ok(());
719 }
720
721 let pre_commit_metadata = DeltaLakePreCommitMetadata::try_from_bytes(&commit_metadata)?;
722 self.commit_actions(
723 epoch,
724 pre_commit_metadata.adds,
725 Some(&pre_commit_metadata.txn_identity),
726 )
727 .await
728 }
729
730 async fn abort(&mut self, epoch: u64, _commit_metadata: Vec<u8>) {
731 tracing::debug!("Abort not implemented yet for DeltaLake epoch {epoch}");
732 }
733}
734
735#[derive(Serialize, Deserialize)]
736struct DeltaLakeWriteResult {
737 adds: Vec<Add>,
738}
739
740#[derive(Serialize, Deserialize)]
741struct DeltaLakePreCommitMetadata {
742 adds: Vec<Add>,
743 txn_identity: DeltaLakeTxnIdentity,
744}
745
746#[derive(Serialize, Deserialize)]
747struct DeltaLakeTxnIdentity {
748 app_id: String,
749 version: i64,
750}
751
752impl DeltaLakePreCommitMetadata {
753 fn try_into_bytes(self) -> Result<Vec<u8>> {
754 Ok(serde_json::to_vec(&self).context("cannot serialize deltalake pre commit metadata")?)
755 }
756
757 fn try_from_bytes(value: &[u8]) -> Result<Self> {
758 Ok(serde_json::from_slice(value)
759 .context("cannot deserialize deltalake pre commit metadata")?)
760 }
761}
762
763impl<'a> TryFrom<&'a DeltaLakeWriteResult> for SinkMetadata {
764 type Error = SinkError;
765
766 fn try_from(value: &'a DeltaLakeWriteResult) -> std::result::Result<Self, Self::Error> {
767 let metadata =
768 serde_json::to_vec(&value.adds).context("cannot serialize deltalake sink metadata")?;
769 Ok(SinkMetadata {
770 metadata: Some(Serialized(SerializedMetadata { metadata })),
771 })
772 }
773}
774
775impl DeltaLakeWriteResult {
776 fn try_from(value: &SinkMetadata) -> Result<Self> {
777 if let Some(Serialized(v)) = &value.metadata {
778 let adds = serde_json::from_slice::<Vec<Add>>(&v.metadata)
779 .context("Can't deserialize deltalake sink metadata")?;
780 Ok(DeltaLakeWriteResult { adds })
781 } else {
782 bail!("Can't create deltalake sink write result from empty data!")
783 }
784 }
785}
786
787impl From<::deltalake::DeltaTableError> for SinkError {
788 fn from(value: ::deltalake::DeltaTableError) -> Self {
789 SinkError::DeltaLake(anyhow!(value))
790 }
791}
792
793#[cfg(all(test, not(madsim)))]
794mod tests {
795 use deltalake::kernel::DataType as SchemaDataType;
796 use deltalake::operations::create::CreateBuilder;
797 use maplit::btreemap;
798 use risingwave_common::array::{Array, I32Array, Op, StreamChunk, Utf8Array};
799 use risingwave_common::catalog::{Field, Schema};
800 use risingwave_common::types::DataType;
801
802 use super::{DeltaLakeConfig, DeltaLakeSinkCommitter, DeltaLakeSinkWriter};
803 use crate::sink::writer::SinkWriter;
804 use crate::sink::{SinglePhaseCommitCoordinator, TwoPhaseCommitCoordinator};
805
806 #[tokio::test]
807 async fn test_deltalake() {
808 let dir = tempfile::tempdir().unwrap();
809 let path = dir.path().to_str().unwrap();
810 CreateBuilder::new()
811 .with_location(path)
812 .with_column(
813 "id",
814 SchemaDataType::Primitive(deltalake::kernel::PrimitiveType::Integer),
815 false,
816 Default::default(),
817 )
818 .with_column(
819 "name",
820 SchemaDataType::Primitive(deltalake::kernel::PrimitiveType::String),
821 false,
822 Default::default(),
823 )
824 .await
825 .unwrap();
826
827 let properties = btreemap! {
828 "connector".to_owned() => "deltalake".to_owned(),
829 "force_append_only".to_owned() => "true".to_owned(),
830 "type".to_owned() => "append-only".to_owned(),
831 "location".to_owned() => format!("file://{}", path),
832 };
833
834 let schema = Schema::new(vec![
835 Field {
836 data_type: DataType::Int32,
837 name: "id".into(),
838 },
839 Field {
840 data_type: DataType::Varchar,
841 name: "name".into(),
842 },
843 ]);
844
845 let deltalake_config = DeltaLakeConfig::from_btreemap(properties).unwrap();
846 let deltalake_table = deltalake_config
847 .common
848 .create_deltalake_client()
849 .await
850 .unwrap();
851
852 let mut deltalake_writer = DeltaLakeSinkWriter::new(deltalake_config, schema, vec![0])
853 .await
854 .unwrap();
855 let chunk = StreamChunk::new(
856 vec![Op::Insert, Op::Insert, Op::Insert],
857 vec![
858 I32Array::from_iter(vec![1, 2, 3]).into_ref(),
859 Utf8Array::from_iter(vec!["Alice", "Bob", "Clare"]).into_ref(),
860 ],
861 );
862 deltalake_writer.write(chunk).await.unwrap();
863 let mut committer = DeltaLakeSinkCommitter {
864 table: deltalake_table,
865 app_id: "test-single-phase".to_owned(),
866 exactly_once: false,
867 };
868 let metadata = deltalake_writer.barrier(true).await.unwrap().unwrap();
869 SinglePhaseCommitCoordinator::commit_data(&mut committer, 1, vec![metadata])
870 .await
871 .unwrap();
872 let snapshot = committer.table.snapshot().unwrap();
873 assert_eq!(1, snapshot.log_data().num_files());
874 }
875
876 #[tokio::test]
877 async fn test_deltalake_exactly_once() {
878 let dir = tempfile::tempdir().unwrap();
879 let path = dir.path().to_str().unwrap();
880 CreateBuilder::new()
881 .with_location(path)
882 .with_column(
883 "id",
884 SchemaDataType::Primitive(deltalake::kernel::PrimitiveType::Integer),
885 false,
886 Default::default(),
887 )
888 .with_column(
889 "name",
890 SchemaDataType::Primitive(deltalake::kernel::PrimitiveType::String),
891 false,
892 Default::default(),
893 )
894 .await
895 .unwrap();
896
897 let properties = btreemap! {
898 "connector".to_owned() => "deltalake".to_owned(),
899 "force_append_only".to_owned() => "true".to_owned(),
900 "type".to_owned() => "append-only".to_owned(),
901 "location".to_owned() => format!("file://{}", path),
902 "is_exactly_once".to_owned() => "true".to_owned(),
903 };
904
905 let schema = Schema::new(vec![
906 Field {
907 data_type: DataType::Int32,
908 name: "id".into(),
909 },
910 Field {
911 data_type: DataType::Varchar,
912 name: "name".into(),
913 },
914 ]);
915
916 let deltalake_config = DeltaLakeConfig::from_btreemap(properties).unwrap();
917 let deltalake_table = deltalake_config
918 .common
919 .create_deltalake_client()
920 .await
921 .unwrap();
922
923 let mut deltalake_writer = DeltaLakeSinkWriter::new(deltalake_config, schema, vec![0])
924 .await
925 .unwrap();
926 let chunk = StreamChunk::new(
927 vec![Op::Insert, Op::Insert, Op::Insert],
928 vec![
929 I32Array::from_iter(vec![1, 2, 3]).into_ref(),
930 Utf8Array::from_iter(vec!["Alice", "Bob", "Clare"]).into_ref(),
931 ],
932 );
933 deltalake_writer.write(chunk).await.unwrap();
934
935 let mut committer = DeltaLakeSinkCommitter {
936 table: deltalake_table,
937 app_id: "test-exactly-once".to_owned(),
938 exactly_once: true,
939 };
940 let metadata = deltalake_writer.barrier(true).await.unwrap().unwrap();
941 let pre_commit_metadata =
942 TwoPhaseCommitCoordinator::pre_commit(&mut committer, 1, vec![metadata], None)
943 .await
944 .unwrap()
945 .unwrap();
946
947 TwoPhaseCommitCoordinator::commit_data(&mut committer, 1, pre_commit_metadata.clone())
948 .await
949 .unwrap();
950 assert_eq!(committer.table.version(), Some(1));
951 assert_eq!(
952 committer.table.snapshot().unwrap().log_data().num_files(),
953 1
954 );
955
956 let log_store = committer.table.log_store();
957 let txn_version = committer
958 .table
959 .snapshot()
960 .unwrap()
961 .transaction_version(log_store.as_ref(), &committer.app_id)
962 .await
963 .unwrap();
964 assert_eq!(txn_version, Some(1));
965
966 TwoPhaseCommitCoordinator::commit_data(&mut committer, 1, pre_commit_metadata)
967 .await
968 .unwrap();
969 assert_eq!(committer.table.version(), Some(1));
970 assert_eq!(
971 committer.table.snapshot().unwrap().log_data().num_files(),
972 1
973 );
974 }
975}