Skip to main content

risingwave_connector/sink/
deltalake.rs

1// Copyright 2025 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 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    /// Commit every n(>0) checkpoints, default is 10.
73    #[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    /// Whether to use the Delta transaction log to deduplicate replayed epoch commits.
202    #[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}