Skip to main content

risingwave_connector/sink/
lancedb.rs

1// Copyright 2026 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 futures::StreamExt;
22use lance::Dataset;
23use lance::dataset::CommitBuilder;
24use lance::dataset::fragment::FileFragment;
25use lance::dataset::transaction::{Operation, TransactionBuilder};
26use lance::dataset::write::WriteParams;
27use lance::io::ObjectStoreParams;
28use lance_table::format::Fragment;
29use lancedb::arrow::{
30    SendableRecordBatchStream, SendableRecordBatchStreamExt, SimpleRecordBatchStream,
31};
32use lancedb::connection::ConnectBuilder;
33use lancedb::{Connection as LanceDbConnection, Table as LanceDbTable};
34use risingwave_common::array::StreamChunk;
35use risingwave_common::array::arrow::LanceDbConvert;
36use risingwave_common::catalog::Schema;
37use risingwave_common::util::iter_util::ZipEqFast;
38use risingwave_pb::connector_service::SinkMetadata;
39use risingwave_pb::connector_service::sink_metadata::Metadata::Serialized;
40use risingwave_pb::connector_service::sink_metadata::SerializedMetadata;
41use risingwave_pb::stream_plan::PbSinkSchemaChange;
42use serde::{Deserialize, Serialize};
43use serde_with::{DisplayFromStr, serde_as};
44use thiserror_ext::AsReport;
45use tokio::sync::mpsc::{self, UnboundedSender};
46use tokio::task::JoinHandle;
47use tokio_stream::wrappers::ReceiverStream;
48use with_options::WithOptions;
49
50use crate::connector_common::IcebergSinkCompactionUpdate;
51use crate::enforce_secret::EnforceSecret;
52use crate::sink::coordinate::CoordinatedLogSinker;
53use crate::sink::decouple_checkpoint_log_sink::default_commit_checkpoint_interval;
54use crate::sink::writer::SinkWriter;
55use crate::sink::{
56    Result, SINK_TYPE_APPEND_ONLY, SINK_USER_FORCE_APPEND_ONLY_OPTION,
57    SinglePhaseCommitCoordinator, Sink, SinkCommitCoordinator, SinkError, SinkParam,
58    SinkWriterParam, TwoPhaseCommitCoordinator,
59};
60
61pub const LANCEDB_SINK: &str = "lancedb";
62
63const RW_EPOCH_TRANSACTION_PROPERTY: &str = "risingwave.epoch";
64const RW_SINK_ID_TRANSACTION_PROPERTY: &str = "risingwave.sink_id";
65const WRITE_CHANNEL_CAPACITY: usize = 16;
66
67// ---------------------------------------------------------------------------
68// Config
69// ---------------------------------------------------------------------------
70
71#[serde_as]
72#[derive(Deserialize, Debug, Clone, WithOptions)]
73pub struct LanceDbCommon {
74    /// URI of the `LanceDB` database (e.g., `/tmp/lancedb`, `<s3://bucket/path/db>`)
75    #[serde(rename = "lancedb.uri")]
76    pub uri: String,
77
78    /// Table name in the `LanceDB` database
79    #[serde(rename = "lancedb.table")]
80    pub table: String,
81
82    /// Commit every n(>0) checkpoints, default is 10.
83    #[serde(default = "default_commit_checkpoint_interval")]
84    #[serde_as(as = "DisplayFromStr")]
85    #[with_option(allow_alter_on_fly)]
86    pub commit_checkpoint_interval: u64,
87}
88
89impl LanceDbCommon {
90    pub async fn create_connection(&self) -> Result<LanceDbConnection> {
91        let conn = ConnectBuilder::new(&self.uri)
92            .execute()
93            .await
94            .context("failed to connect to LanceDB")
95            .map_err(SinkError::LanceDb)?;
96        Ok(conn)
97    }
98
99    async fn open_table(&self, conn: &LanceDbConnection) -> Result<LanceDbTable> {
100        conn.open_table(&self.table)
101            .execute()
102            .await
103            .context("failed to open LanceDB table")
104            .map_err(SinkError::LanceDb)
105    }
106
107    /// Get the Lance dataset URI from the opened native `LanceDB` table.
108    async fn dataset_uri(&self, table: &LanceDbTable) -> Result<String> {
109        let dataset_wrapper = table.dataset().ok_or_else(|| {
110            SinkError::LanceDb(anyhow!(
111                "failed to get underlying lance Dataset (table may be remote)"
112            ))
113        })?;
114        let dataset_guard = dataset_wrapper
115            .get()
116            .await
117            .map_err(|e| SinkError::LanceDb(anyhow!(e)))?;
118        Ok(dataset_guard.uri().to_owned())
119    }
120}
121
122#[serde_as]
123#[derive(Clone, Debug, Deserialize, WithOptions)]
124pub struct LanceDbConfig {
125    #[serde(flatten)]
126    pub common: LanceDbCommon,
127
128    pub r#type: String,
129
130    /// Whether to use RisingWave's two-phase commit framework for exactly-once commits.
131    /// Defaults to true. Set to false to use single-phase commits.
132    #[serde_as(as = "Option<DisplayFromStr>")]
133    pub is_exactly_once: Option<bool>,
134}
135
136impl LanceDbConfig {
137    pub fn from_btreemap(properties: BTreeMap<String, String>) -> Result<Self> {
138        let config = serde_json::from_value::<LanceDbConfig>(
139            serde_json::to_value(properties).map_err(|e| SinkError::LanceDb(e.into()))?,
140        )
141        .map_err(|e| SinkError::Config(anyhow!(e)))?;
142        Ok(config)
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Sink
148// ---------------------------------------------------------------------------
149
150#[derive(Debug)]
151pub struct LanceDbSink {
152    pub config: LanceDbConfig,
153    param: SinkParam,
154}
155
156impl EnforceSecret for LanceDbSink {
157    fn enforce_secret<'a>(
158        _prop_iter: impl Iterator<Item = &'a str>,
159    ) -> crate::error::ConnectorResult<()> {
160        // LanceDB currently has no secret properties.
161        Ok(())
162    }
163}
164
165impl LanceDbSink {
166    pub fn new(config: LanceDbConfig, param: SinkParam) -> Result<Self> {
167        Ok(Self { config, param })
168    }
169}
170
171impl Sink for LanceDbSink {
172    type LogSinker = CoordinatedLogSinker<LanceDbSinkWriter>;
173
174    const SINK_NAME: &'static str = LANCEDB_SINK;
175
176    fn is_exactly_once(properties: &BTreeMap<String, String>) -> Result<bool> {
177        let Some(value) = properties.get("is_exactly_once") else {
178            return Ok(true);
179        };
180        value.parse::<bool>().map_err(|_| {
181            SinkError::Config(anyhow!(
182                "invalid value for `is_exactly_once`: expected `true` or `false`, got `{value}`"
183            ))
184        })
185    }
186
187    async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
188        let inner = LanceDbSinkWriter::new(self.config.clone(), self.param.schema()).await?;
189
190        let commit_checkpoint_interval =
191            NonZeroU64::new(self.config.common.commit_checkpoint_interval).expect(
192                "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
193            );
194
195        let writer = CoordinatedLogSinker::new(
196            &writer_param,
197            self.param.clone(),
198            inner,
199            commit_checkpoint_interval,
200        )
201        .await?;
202
203        Ok(writer)
204    }
205
206    fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
207        LanceDbConfig::from_btreemap(config.clone())?;
208        Ok(())
209    }
210
211    async fn validate(&self) -> Result<()> {
212        // Only append-only is supported
213        if self.config.r#type != SINK_TYPE_APPEND_ONLY
214            && self.config.r#type != SINK_USER_FORCE_APPEND_ONLY_OPTION
215        {
216            return Err(SinkError::Config(anyhow!(
217                "only append-only LanceDB sink is supported",
218            )));
219        }
220
221        if self.config.common.commit_checkpoint_interval == 0 {
222            return Err(SinkError::Config(anyhow!(
223                "`commit_checkpoint_interval` must be greater than 0"
224            )));
225        }
226
227        // Validate connection
228        let conn = self.config.common.create_connection().await?;
229
230        // Validate table exists and schema is compatible
231        let table = self.config.common.open_table(&conn).await?;
232
233        // Get the Lance table schema (Arrow schema)
234        let lance_schema = table
235            .schema()
236            .await
237            .context("failed to get LanceDB table schema")
238            .map_err(SinkError::LanceDb)?;
239
240        // Convert RW schema to arrow schema and compare
241        let rw_schema = self.param.schema();
242        let rw_arrow_schema = LanceDbConvert
243            .rw_schema_to_arrow_schema(&rw_schema)
244            .map_err(|e| SinkError::LanceDb(anyhow!(e)))?;
245
246        validate_ordered_schema(&rw_arrow_schema, lance_schema.as_ref())?;
247
248        Ok(())
249    }
250
251    fn is_coordinated_sink(&self) -> bool {
252        true
253    }
254
255    async fn new_coordinator(
256        &self,
257        _iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
258    ) -> Result<SinkCommitCoordinator> {
259        let committer =
260            LanceDbSinkCommitter::new(self.config.clone(), self.param.sink_id.to_string()).await?;
261        if Self::is_exactly_once(&self.param.properties)? {
262            Ok(SinkCommitCoordinator::TwoPhase(Box::new(committer)))
263        } else {
264            Ok(SinkCommitCoordinator::SinglePhase(Box::new(committer)))
265        }
266    }
267}
268
269impl TryFrom<SinkParam> for LanceDbSink {
270    type Error = SinkError;
271
272    fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
273        let config = LanceDbConfig::from_btreemap(param.properties.clone())?;
274        LanceDbSink::new(config, param)
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Writer
280// ---------------------------------------------------------------------------
281
282// Re-export arrow types from the LanceDb arrow module so they're used consistently.
283use risingwave_common::array::arrow::{
284    arrow_array_lancedb as arrow_array, arrow_schema_lancedb as arrow_schema,
285};
286
287fn validate_ordered_schema(
288    rw_arrow_schema: &arrow_schema::Schema,
289    lance_schema: &arrow_schema::Schema,
290) -> Result<()> {
291    if rw_arrow_schema.fields().len() != lance_schema.fields().len() {
292        return Err(SinkError::LanceDb(anyhow!(
293            "Columns mismatch. RisingWave schema has {} fields, LanceDB table has {} fields",
294            rw_arrow_schema.fields().len(),
295            lance_schema.fields().len()
296        )));
297    }
298
299    for (idx, (rw_field, lance_field)) in rw_arrow_schema
300        .fields()
301        .iter()
302        .zip_eq_fast(lance_schema.fields().iter())
303        .enumerate()
304    {
305        if rw_field.name() != lance_field.name() {
306            return Err(SinkError::LanceDb(anyhow!(
307                "column order mismatch at position {}: LanceDB column is '{}', RisingWave column is '{}'",
308                idx,
309                lance_field.name(),
310                rw_field.name()
311            )));
312        }
313
314        if rw_field.data_type() != lance_field.data_type() {
315            return Err(SinkError::LanceDb(anyhow!(
316                "column '{}' type mismatch: LanceDB type is {:?}, RisingWave type is {:?}",
317                rw_field.name(),
318                lance_field.data_type(),
319                rw_field.data_type()
320            )));
321        }
322    }
323
324    Ok(())
325}
326
327/// The writer writes data files directly to the Lance dataset storage using the
328/// low-level `FileFragment::create_fragments()` API. On checkpoint, it returns
329/// lightweight `Fragment` metadata instead of the actual data payload. A fragment
330/// is a logical row segment that references one or more files containing columns
331/// for those rows. The coordinator then commits these fragments atomically.
332///
333/// This follows the same pattern as the Iceberg sink, where writers handle I/O
334/// and the coordinator only performs a metadata-only commit.
335pub struct LanceDbSinkWriter {
336    pub config: LanceDbConfig,
337    #[expect(dead_code)]
338    schema: Schema,
339    arrow_schema: Arc<arrow_schema::Schema>,
340    /// Dataset URI for the target Lance table (e.g., `/tmp/lancedb/my_table.lance`)
341    dataset_uri: String,
342    /// Lance write parameters matching the target dataset format.
343    write_params: WriteParams,
344    fragment_write: Option<FragmentWrite>,
345}
346
347struct FragmentWrite {
348    sender: Option<mpsc::Sender<arrow_array::RecordBatch>>,
349    task: Option<JoinHandle<Result<Vec<Fragment>>>>,
350}
351
352impl Drop for FragmentWrite {
353    fn drop(&mut self) {
354        if let Some(task) = &self.task {
355            task.abort();
356        }
357    }
358}
359
360impl LanceDbSinkWriter {
361    pub async fn new(config: LanceDbConfig, schema: Schema) -> Result<Self> {
362        let arrow_schema = LanceDbConvert
363            .rw_schema_to_arrow_schema(&schema)
364            .map_err(|e| SinkError::LanceDb(anyhow!(e)))?;
365
366        let conn = config.common.create_connection().await?;
367        let table = config.common.open_table(&conn).await?;
368        let dataset_wrapper = table.dataset().ok_or_else(|| {
369            SinkError::LanceDb(anyhow!(
370                "failed to get underlying lance Dataset (table may be remote)"
371            ))
372        })?;
373        let dataset_guard = dataset_wrapper
374            .get()
375            .await
376            .map_err(|e| SinkError::LanceDb(anyhow!(e)))?;
377        let data_storage_version = dataset_guard
378            .manifest
379            .data_storage_format
380            .lance_file_version()
381            .context("failed to get LanceDB table storage version")
382            .map_err(SinkError::LanceDb)?;
383        let store_params =
384            dataset_guard
385                .storage_options_accessor()
386                .map(|storage_options_accessor| ObjectStoreParams {
387                    storage_options_accessor: Some(storage_options_accessor),
388                    ..Default::default()
389                });
390        drop(dataset_guard);
391        let dataset_uri = config.common.dataset_uri(&table).await?;
392
393        Ok(Self {
394            config,
395            schema,
396            arrow_schema: Arc::new(arrow_schema),
397            dataset_uri,
398            write_params: WriteParams {
399                // Reuse the opened dataset's storage options so fragment writes use the same
400                // credentials and object-store configuration as table access.
401                store_params,
402                // Lance otherwise chooses its latest stable format. Detached fragments must use
403                // the existing table's format so they remain compatible when committed later.
404                data_storage_version: Some(data_storage_version),
405                ..Default::default()
406            },
407            fragment_write: None,
408        })
409    }
410
411    fn start_fragment_write(&self) -> FragmentWrite {
412        let (sender, receiver) = mpsc::channel(WRITE_CHANNEL_CAPACITY);
413        let stream = ReceiverStream::new(receiver).map(Ok::<_, lancedb::Error>);
414        let stream: SendableRecordBatchStream = Box::pin(SimpleRecordBatchStream::new(
415            stream,
416            self.arrow_schema.clone(),
417        ));
418        let stream = stream.into_df_stream();
419        let dataset_uri = self.dataset_uri.clone();
420        let write_params = self.write_params.clone();
421        let task = tokio::spawn(async move {
422            FileFragment::create_fragments(&dataset_uri, stream, Some(write_params))
423                .await
424                .context("failed to write lance data files")
425                .map_err(SinkError::LanceDb)
426        });
427
428        FragmentWrite {
429            sender: Some(sender),
430            task: Some(task),
431        }
432    }
433
434    async fn finish_fragment_write(&mut self) -> Result<Vec<Fragment>> {
435        let Some(mut fragment_write) = self.fragment_write.take() else {
436            return Ok(Vec::new());
437        };
438
439        drop(fragment_write.sender.take());
440        fragment_write
441            .task
442            .take()
443            .expect("fragment write task should be initialized")
444            .await
445            .context("Lance fragment write task failed")
446            .map_err(SinkError::LanceDb)?
447    }
448}
449
450#[async_trait]
451impl SinkWriter for LanceDbSinkWriter {
452    type CommitMetadata = Option<SinkMetadata>;
453
454    async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
455        let record_batch = LanceDbConvert
456            .to_record_batch(self.arrow_schema.clone(), &chunk)
457            .context("failed to convert DataChunk to RecordBatch for LanceDB")
458            .map_err(SinkError::LanceDb)?;
459
460        if self.fragment_write.is_none() {
461            self.fragment_write = Some(self.start_fragment_write());
462        }
463        self.fragment_write
464            .as_ref()
465            .expect("fragment write should be initialized")
466            .sender
467            .as_ref()
468            .expect("fragment write sender should be initialized")
469            .send(record_batch)
470            .await
471            .map_err(|_| {
472                SinkError::LanceDb(anyhow!(
473                    "Lance fragment write task stopped before accepting a record batch"
474                ))
475            })?;
476        Ok(())
477    }
478
479    async fn begin_epoch(&mut self, _epoch: u64) -> Result<()> {
480        Ok(())
481    }
482
483    async fn abort(&mut self) -> Result<()> {
484        let Some(mut fragment_write) = self.fragment_write.take() else {
485            return Ok(());
486        };
487
488        drop(fragment_write.sender.take());
489        match fragment_write
490            .task
491            .take()
492            .expect("fragment write task should be initialized")
493            .await
494        {
495            Ok(Ok(fragments)) => {
496                tracing::debug!(
497                    fragment_count = fragments.len(),
498                    "Left uncommitted Lance fragments for table-level orphan cleanup after sink writer abort"
499                );
500            }
501            Ok(Err(error)) => {
502                tracing::warn!(
503                    error = %error.as_report(),
504                    "Lance fragment write failed while aborting sink writer"
505                );
506            }
507            Err(error) => {
508                tracing::warn!(
509                    error = %error.as_report(),
510                    "Lance fragment write task failed while aborting sink writer"
511                );
512            }
513        }
514        Ok(())
515    }
516
517    async fn barrier(&mut self, is_checkpoint: bool) -> Result<Option<SinkMetadata>> {
518        if !is_checkpoint {
519            return Ok(None);
520        }
521
522        let fragments = self.finish_fragment_write().await?;
523
524        // Serialize fragment metadata as JSON — this is lightweight (file paths + row counts).
525        let metadata = serde_json::to_vec(&fragments)
526            .context("failed to serialize fragment metadata")
527            .map_err(SinkError::LanceDb)?;
528
529        Ok(Some(SinkMetadata {
530            metadata: Some(Serialized(SerializedMetadata { metadata })),
531        }))
532    }
533}
534
535// ---------------------------------------------------------------------------
536// Committer (Coordinator)
537// ---------------------------------------------------------------------------
538
539/// The coordinator collects lightweight `Fragment` metadata from all writers
540/// and commits them atomically via `CommitBuilder`.
541/// No data payload flows through the coordinator — only file-level metadata.
542pub struct LanceDbSinkCommitter {
543    config: LanceDbConfig,
544    conn: LanceDbConnection,
545    sink_id: String,
546}
547
548impl LanceDbSinkCommitter {
549    pub async fn new(config: LanceDbConfig, sink_id: String) -> Result<Self> {
550        let conn = config.common.create_connection().await?;
551        Ok(Self {
552            config,
553            conn,
554            sink_id,
555        })
556    }
557
558    fn collect_fragments(metadata: &[SinkMetadata]) -> Result<Vec<Fragment>> {
559        let mut all_fragments = Vec::new();
560        for meta in metadata {
561            if let Some(Serialized(s)) = &meta.metadata {
562                let fragments: Vec<Fragment> = serde_json::from_slice(&s.metadata)
563                    .context("failed to deserialize fragment metadata")
564                    .map_err(SinkError::LanceDb)?;
565                all_fragments.extend(fragments);
566            }
567        }
568        Ok(all_fragments)
569    }
570
571    async fn is_epoch_committed(&self, dataset: &Dataset, target_epoch: u64) -> Result<bool> {
572        let mut dataset = dataset.clone();
573
574        loop {
575            if let Some(transaction) = dataset
576                .read_transaction()
577                .await
578                .context("failed to read Lance transaction history")
579                .map_err(SinkError::LanceDb)?
580                && let Some(properties) = transaction.transaction_properties
581                && properties
582                    .get(RW_SINK_ID_TRANSACTION_PROPERTY)
583                    .is_some_and(|sink_id| sink_id == &self.sink_id)
584                && let Some(committed_epoch) = properties.get(RW_EPOCH_TRANSACTION_PROPERTY)
585            {
586                let committed_epoch = committed_epoch
587                    .parse::<u64>()
588                    .context("invalid RisingWave epoch in Lance transaction history")
589                    .map_err(SinkError::LanceDb)?;
590                return Ok(committed_epoch >= target_epoch);
591            }
592
593            let version = dataset.version().version;
594            if version <= 1 {
595                return Ok(false);
596            }
597
598            dataset = match dataset.checkout_version(version - 1).await {
599                Ok(dataset) => dataset,
600                // Lance cleanup removes a contiguous prefix of old versions. Reaching a
601                // missing previous version therefore means that all retained history has
602                // already been inspected.
603                Err(lance::Error::DatasetNotFound { .. }) => return Ok(false),
604                Err(error) => {
605                    return Err(SinkError::LanceDb(
606                        anyhow!(error).context("failed to read Lance transaction history"),
607                    ));
608                }
609            };
610        }
611    }
612
613    async fn commit_fragments(
614        &mut self,
615        epoch: u64,
616        fragments: Vec<Fragment>,
617        transaction_properties: Option<HashMap<String, String>>,
618    ) -> Result<()> {
619        if fragments.is_empty() {
620            tracing::debug!("No fragments to commit in epoch {epoch}, skipping.");
621            return Ok(());
622        }
623
624        // Open the table to get the underlying lance Dataset.
625        let table = self
626            .conn
627            .open_table(&self.config.common.table)
628            .execute()
629            .await
630            .context("failed to open LanceDB table for commit")
631            .map_err(SinkError::LanceDb)?;
632
633        let dataset_wrapper = table.dataset().ok_or_else(|| {
634            SinkError::LanceDb(anyhow!(
635                "failed to get underlying lance Dataset (table may be remote)"
636            ))
637        })?;
638
639        let dataset_guard = dataset_wrapper
640            .get()
641            .await
642            .map_err(|e| SinkError::LanceDb(anyhow!(e)))?;
643        let dataset = (*dataset_guard).clone();
644        drop(dataset_guard);
645
646        let data_storage_version = dataset
647            .manifest
648            .data_storage_format
649            .lance_file_version()
650            .context("failed to get LanceDB table storage version")
651            .map_err(SinkError::LanceDb)?;
652
653        if transaction_properties.is_some() && self.is_epoch_committed(&dataset, epoch).await? {
654            tracing::info!(
655                "LanceDB epoch {epoch} has already been committed, skipping duplicate commit."
656            );
657            return Ok(());
658        }
659
660        // Commit fragments via low-level Lance transaction with Operation::Append.
661        // This is a metadata-only operation — the data files were already written by workers.
662        let operation = Operation::Append { fragments };
663
664        let mut transaction_builder = TransactionBuilder::new(dataset.version().version, operation);
665        if let Some(transaction_properties) = transaction_properties {
666            transaction_builder =
667                transaction_builder.transaction_properties(Some(Arc::new(transaction_properties)));
668        }
669        let transaction = transaction_builder.build();
670
671        let new_dataset = CommitBuilder::new(Arc::new(dataset))
672            .with_storage_format(data_storage_version)
673            .execute(transaction)
674            .await
675            .context("failed to commit fragments to lance dataset")
676            .map_err(SinkError::LanceDb)?;
677
678        // Update the lancedb Table's internal dataset to the new version.
679        dataset_wrapper.update(new_dataset);
680
681        tracing::debug!("Succeeded to commit fragments to LanceDB table in epoch {epoch}.");
682        Ok(())
683    }
684}
685
686#[async_trait::async_trait]
687impl SinglePhaseCommitCoordinator for LanceDbSinkCommitter {
688    async fn init(&mut self) -> Result<()> {
689        tracing::info!(
690            "LanceDB commit coordinator initialized for table '{}'",
691            self.config.common.table
692        );
693        Ok(())
694    }
695
696    async fn commit_data(&mut self, epoch: u64, metadata: Vec<SinkMetadata>) -> Result<()> {
697        tracing::debug!("Starting LanceDB single-phase commit in epoch {epoch}.");
698
699        let fragments = Self::collect_fragments(&metadata)?;
700        self.commit_fragments(epoch, fragments, None).await
701    }
702}
703
704#[async_trait::async_trait]
705impl TwoPhaseCommitCoordinator for LanceDbSinkCommitter {
706    async fn init(&mut self) -> Result<()> {
707        tracing::info!(
708            "LanceDB commit coordinator initialized for table '{}'",
709            self.config.common.table
710        );
711        Ok(())
712    }
713
714    async fn pre_commit(
715        &mut self,
716        epoch: u64,
717        metadata: Vec<SinkMetadata>,
718        schema_change: Option<PbSinkSchemaChange>,
719    ) -> Result<Option<Vec<u8>>> {
720        if schema_change.is_some() {
721            return Err(SinkError::LanceDb(anyhow!(
722                "LanceDB sink does not support schema change"
723            )));
724        }
725
726        let fragments = Self::collect_fragments(&metadata)?;
727        if fragments.is_empty() {
728            return Ok(None);
729        }
730
731        Ok(Some(
732            LanceDbPreCommitMetadata {
733                sink_id: self.sink_id.clone(),
734                epoch,
735                fragments,
736            }
737            .try_into_bytes()?,
738        ))
739    }
740
741    async fn commit_data(&mut self, epoch: u64, commit_metadata: Vec<u8>) -> Result<()> {
742        tracing::debug!("Starting LanceDB two-phase commit in epoch {epoch}.");
743
744        if commit_metadata.is_empty() {
745            return Ok(());
746        }
747
748        let pre_commit_metadata = LanceDbPreCommitMetadata::try_from_bytes(&commit_metadata)?;
749        if pre_commit_metadata.epoch != epoch {
750            return Err(SinkError::LanceDb(anyhow!(
751                "LanceDB pre-commit epoch {} does not match commit epoch {}",
752                pre_commit_metadata.epoch,
753                epoch
754            )));
755        }
756        if pre_commit_metadata.sink_id != self.sink_id {
757            return Err(SinkError::LanceDb(anyhow!(
758                "LanceDB pre-commit sink id {} does not match coordinator sink id {}",
759                pre_commit_metadata.sink_id,
760                self.sink_id
761            )));
762        }
763
764        let transaction_properties = pre_commit_metadata.transaction_properties();
765        self.commit_fragments(
766            epoch,
767            pre_commit_metadata.fragments,
768            Some(transaction_properties),
769        )
770        .await
771    }
772
773    async fn abort(&mut self, epoch: u64, _commit_metadata: Vec<u8>) {
774        // Unreferenced files are reclaimed by Lance's old-version/orphan cleanup. This is
775        // intentionally not an eager delete: the commit may have succeeded even if its result
776        // was lost, and process crashes can bypass this callback entirely.
777        tracing::debug!("Left LanceDB epoch {epoch} for table-level orphan cleanup");
778    }
779}
780
781#[derive(Serialize, Deserialize)]
782struct LanceDbPreCommitMetadata {
783    sink_id: String,
784    epoch: u64,
785    fragments: Vec<Fragment>,
786}
787
788impl LanceDbPreCommitMetadata {
789    fn try_into_bytes(self) -> Result<Vec<u8>> {
790        serde_json::to_vec(&self)
791            .context("cannot serialize LanceDB pre-commit metadata")
792            .map_err(SinkError::LanceDb)
793    }
794
795    fn try_from_bytes(value: &[u8]) -> Result<Self> {
796        serde_json::from_slice(value)
797            .context("cannot deserialize LanceDB pre-commit metadata")
798            .map_err(SinkError::LanceDb)
799    }
800
801    fn transaction_properties(&self) -> HashMap<String, String> {
802        HashMap::from([
803            (
804                RW_SINK_ID_TRANSACTION_PROPERTY.to_owned(),
805                self.sink_id.clone(),
806            ),
807            (
808                RW_EPOCH_TRANSACTION_PROPERTY.to_owned(),
809                self.epoch.to_string(),
810            ),
811        ])
812    }
813}
814
815// ---------------------------------------------------------------------------
816// Error conversion
817// ---------------------------------------------------------------------------
818
819impl From<lancedb::Error> for SinkError {
820    fn from(value: lancedb::Error) -> Self {
821        SinkError::LanceDb(anyhow!(value))
822    }
823}
824
825// ---------------------------------------------------------------------------
826// Tests
827// ---------------------------------------------------------------------------
828
829#[cfg(all(test, not(madsim)))]
830mod tests {
831    use risingwave_common::array::{Array, I32Array, Op, StreamChunk, Utf8Array};
832    use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema};
833    use risingwave_common::id::SinkId;
834    use risingwave_common::types::DataType;
835
836    use super::*;
837    use crate::sink::catalog::SinkType;
838    use crate::sink::writer::SinkWriter;
839    use crate::sink::{SinglePhaseCommitCoordinator, TwoPhaseCommitCoordinator};
840
841    #[tokio::test]
842    async fn test_lancedb_sink_roundtrip() {
843        // 1. Create a temp directory for LanceDB
844        let dir = tempfile::tempdir().unwrap();
845        let uri = dir.path().to_str().unwrap();
846
847        // 2. Create a LanceDB table with a schema
848        let conn = lancedb::connect(uri).execute().await.unwrap();
849
850        // Create a schema-only table, matching the e2e test setup.
851        let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![
852            arrow_schema::Field::new("id", arrow_schema::DataType::Int32, false),
853            arrow_schema::Field::new("name", arrow_schema::DataType::Utf8, false),
854        ]));
855        conn.create_table(
856            "test_table",
857            arrow_array::RecordBatch::new_empty(arrow_schema.clone()),
858        )
859        .execute()
860        .await
861        .unwrap();
862
863        // 3. Create a LanceDB sink writer
864        let properties: BTreeMap<String, String> = [
865            ("connector".to_owned(), "lancedb".to_owned()),
866            ("type".to_owned(), "append-only".to_owned()),
867            ("lancedb.uri".to_owned(), uri.to_owned()),
868            ("lancedb.table".to_owned(), "test_table".to_owned()),
869        ]
870        .into();
871
872        let schema = Schema::new(vec![
873            Field {
874                data_type: DataType::Int32,
875                name: "id".into(),
876            },
877            Field {
878                data_type: DataType::Varchar,
879                name: "name".into(),
880            },
881        ]);
882
883        let config = LanceDbConfig::from_btreemap(properties).unwrap();
884        assert_eq!(config.is_exactly_once, None);
885        let mut writer = LanceDbSinkWriter::new(config.clone(), schema.clone())
886            .await
887            .unwrap();
888        let table = conn.open_table("test_table").execute().await.unwrap();
889        assert_eq!(writer.dataset_uri, table.uri().await.unwrap());
890
891        // 4. Abort one streamed batch, then write two batches for the checkpoint.
892        let chunk = StreamChunk::new(
893            vec![Op::Insert],
894            vec![
895                I32Array::from_iter(vec![0]).into_ref(),
896                Utf8Array::from_iter(vec!["Aborted"]).into_ref(),
897            ],
898        );
899        writer.write_batch(chunk).await.unwrap();
900        writer.abort().await.unwrap();
901
902        let chunk = StreamChunk::new(
903            vec![Op::Insert, Op::Insert],
904            vec![
905                I32Array::from_iter(vec![1, 2]).into_ref(),
906                Utf8Array::from_iter(vec!["Alice", "Bob"]).into_ref(),
907            ],
908        );
909        writer.write_batch(chunk).await.unwrap();
910        let chunk = StreamChunk::new(
911            vec![Op::Insert],
912            vec![
913                I32Array::from_iter(vec![3]).into_ref(),
914                Utf8Array::from_iter(vec!["Clare"]).into_ref(),
915            ],
916        );
917        writer.write_batch(chunk).await.unwrap();
918
919        // 5. Barrier → get SinkMetadata (lightweight fragment metadata, not data payload)
920        let metadata = writer.barrier(true).await.unwrap().unwrap();
921
922        // Verify the metadata is lightweight JSON (not Arrow IPC payload)
923        if let Some(Serialized(s)) = &metadata.metadata {
924            let fragments: Vec<Fragment> = serde_json::from_slice(&s.metadata).unwrap();
925            assert!(!fragments.is_empty(), "should have at least one fragment");
926            // Fragment metadata should be much smaller than actual data
927            assert!(
928                s.metadata.len() < 4096,
929                "fragment metadata should be lightweight, got {} bytes",
930                s.metadata.len()
931            );
932        } else {
933            panic!("expected serialized metadata");
934        }
935
936        // 6. Commit via two-phase coordinator (metadata-only commit)
937        let mut committer = LanceDbSinkCommitter::new(config.clone(), "test-sink".to_owned())
938            .await
939            .unwrap();
940        TwoPhaseCommitCoordinator::init(&mut committer)
941            .await
942            .unwrap();
943        let commit_metadata =
944            TwoPhaseCommitCoordinator::pre_commit(&mut committer, 1, vec![metadata], None)
945                .await
946                .unwrap()
947                .unwrap();
948        TwoPhaseCommitCoordinator::commit_data(&mut committer, 1, commit_metadata.clone())
949            .await
950            .unwrap();
951
952        // 7. Verify data was written by reading back
953        let table = conn.open_table("test_table").execute().await.unwrap();
954        let count = table.count_rows(None).await.unwrap();
955        assert_eq!(count, 3);
956        let dataset_wrapper = table.dataset().unwrap();
957        let dataset_guard = dataset_wrapper.get().await.unwrap();
958        let transaction = dataset_guard.read_transaction().await.unwrap().unwrap();
959        let transaction_properties = transaction.transaction_properties.unwrap();
960        assert_eq!(
961            transaction_properties.get(RW_SINK_ID_TRANSACTION_PROPERTY),
962            Some(&"test-sink".to_owned())
963        );
964        assert!(
965            committer
966                .is_epoch_committed(&dataset_guard, 0)
967                .await
968                .unwrap()
969        );
970        assert!(
971            committer
972                .is_epoch_committed(&dataset_guard, 1)
973                .await
974                .unwrap()
975        );
976        assert!(
977            !committer
978                .is_epoch_committed(&dataset_guard, 2)
979                .await
980                .unwrap()
981        );
982
983        // 8. An independent sink must not use another sink's epoch marker.
984        let replacement_committer =
985            LanceDbSinkCommitter::new(config.clone(), "replacement-sink".to_owned())
986                .await
987                .unwrap();
988        assert!(
989            !replacement_committer
990                .is_epoch_committed(&dataset_guard, 1)
991                .await
992                .unwrap()
993        );
994        drop(dataset_guard);
995
996        // 9. Retrying the same committed epoch should be idempotent in two-phase mode.
997        TwoPhaseCommitCoordinator::commit_data(&mut committer, 1, commit_metadata)
998            .await
999            .unwrap();
1000        let table = conn.open_table("test_table").execute().await.unwrap();
1001        let count = table.count_rows(None).await.unwrap();
1002        assert_eq!(count, 3);
1003
1004        // 10. Single-phase commit remains available when exactly-once is disabled.
1005        let mut writer = LanceDbSinkWriter::new(config.clone(), schema)
1006            .await
1007            .unwrap();
1008        let chunk = StreamChunk::new(
1009            vec![Op::Insert],
1010            vec![
1011                I32Array::from_iter(vec![4]).into_ref(),
1012                Utf8Array::from_iter(vec!["David"]).into_ref(),
1013            ],
1014        );
1015        writer.write_batch(chunk).await.unwrap();
1016        let metadata = writer.barrier(true).await.unwrap().unwrap();
1017
1018        let mut single_phase_committer = LanceDbSinkCommitter::new(config, "test-sink".to_owned())
1019            .await
1020            .unwrap();
1021        SinglePhaseCommitCoordinator::init(&mut single_phase_committer)
1022            .await
1023            .unwrap();
1024        SinglePhaseCommitCoordinator::commit_data(&mut single_phase_committer, 2, vec![metadata])
1025            .await
1026            .unwrap();
1027        let table = conn.open_table("test_table").execute().await.unwrap();
1028        let count = table.count_rows(None).await.unwrap();
1029        assert_eq!(count, 4);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_epoch_lookup_stops_at_pruned_history() {
1034        let dir = tempfile::tempdir().unwrap();
1035        let uri = dir.path().to_str().unwrap();
1036        let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
1037            "id",
1038            arrow_schema::DataType::Int32,
1039            false,
1040        )]));
1041        let batch = arrow_array::RecordBatch::try_new(
1042            arrow_schema.clone(),
1043            vec![Arc::new(arrow_array::Int32Array::from(vec![0i32]))],
1044        )
1045        .unwrap();
1046
1047        let conn = lancedb::connect(uri).execute().await.unwrap();
1048        let table = conn
1049            .create_table("test_table", batch.clone())
1050            .execute()
1051            .await
1052            .unwrap();
1053        table.add(batch).execute().await.unwrap();
1054
1055        let dataset_wrapper = table.dataset().unwrap();
1056        let dataset_guard = dataset_wrapper.get().await.unwrap();
1057        dataset_guard
1058            .cleanup_old_versions(chrono::Duration::zero(), Some(true), Some(false))
1059            .await
1060            .unwrap();
1061        drop(dataset_guard);
1062        drop(table);
1063        drop(conn);
1064
1065        // Reopen with a fresh session so a cached old manifest cannot hide the pruning boundary.
1066        let conn = lancedb::connect(uri).execute().await.unwrap();
1067        let table = conn.open_table("test_table").execute().await.unwrap();
1068        let dataset_wrapper = table.dataset().unwrap();
1069        let dataset_guard = dataset_wrapper.get().await.unwrap();
1070        assert!(dataset_guard.checkout_version(1).await.is_err());
1071
1072        let properties: BTreeMap<String, String> = [
1073            ("connector".to_owned(), "lancedb".to_owned()),
1074            ("type".to_owned(), "append-only".to_owned()),
1075            ("lancedb.uri".to_owned(), uri.to_owned()),
1076            ("lancedb.table".to_owned(), "test_table".to_owned()),
1077        ]
1078        .into();
1079        let config = LanceDbConfig::from_btreemap(properties).unwrap();
1080        let committer = LanceDbSinkCommitter::new(config, "test-sink".to_owned())
1081            .await
1082            .unwrap();
1083
1084        assert!(
1085            !committer
1086                .is_epoch_committed(&dataset_guard, 1)
1087                .await
1088                .unwrap()
1089        );
1090    }
1091
1092    #[test]
1093    fn test_validate_ordered_schema_rejects_reordered_columns() {
1094        let rw_schema = arrow_schema::Schema::new(vec![
1095            arrow_schema::Field::new("id", arrow_schema::DataType::Int32, false),
1096            arrow_schema::Field::new("name", arrow_schema::DataType::Utf8, false),
1097        ]);
1098        let reordered_lance_schema = arrow_schema::Schema::new(vec![
1099            arrow_schema::Field::new("name", arrow_schema::DataType::Utf8, false),
1100            arrow_schema::Field::new("id", arrow_schema::DataType::Int32, false),
1101        ]);
1102
1103        let err = validate_ordered_schema(&rw_schema, &reordered_lance_schema).unwrap_err();
1104        assert!(
1105            format!("{err:?}").contains("column order mismatch"),
1106            "unexpected error: {err:?}"
1107        );
1108    }
1109
1110    #[tokio::test]
1111    async fn test_validate_rejects_reordered_lancedb_table_columns() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let uri = dir.path().to_str().unwrap();
1114
1115        let conn = lancedb::connect(uri).execute().await.unwrap();
1116        let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![
1117            arrow_schema::Field::new("name", arrow_schema::DataType::Utf8, false),
1118            arrow_schema::Field::new("id", arrow_schema::DataType::Int32, false),
1119        ]));
1120        let batch = arrow_array::RecordBatch::try_new(
1121            arrow_schema,
1122            vec![
1123                Arc::new(arrow_array::StringArray::from(vec!["init"])),
1124                Arc::new(arrow_array::Int32Array::from(vec![0i32])),
1125            ],
1126        )
1127        .unwrap();
1128        conn.create_table("reordered_table", batch)
1129            .execute()
1130            .await
1131            .unwrap();
1132
1133        let properties: BTreeMap<String, String> = [
1134            ("connector".to_owned(), "lancedb".to_owned()),
1135            ("type".to_owned(), "append-only".to_owned()),
1136            ("lancedb.uri".to_owned(), uri.to_owned()),
1137            ("lancedb.table".to_owned(), "reordered_table".to_owned()),
1138        ]
1139        .into();
1140        let config = LanceDbConfig::from_btreemap(properties.clone()).unwrap();
1141        let param = SinkParam {
1142            sink_id: SinkId::from(1u32),
1143            sink_name: "test_sink".to_owned(),
1144            properties,
1145            columns: vec![
1146                ColumnDesc::named("id", ColumnId::new(1), DataType::Int32),
1147                ColumnDesc::named("name", ColumnId::new(2), DataType::Varchar),
1148            ],
1149            downstream_pk: None,
1150            sink_type: SinkType::AppendOnly,
1151            ignore_delete: false,
1152            format_desc: None,
1153            db_name: "dev".to_owned(),
1154            sink_from_name: "test_sink".to_owned(),
1155        };
1156
1157        let sink = LanceDbSink::new(config, param).unwrap();
1158        let err = sink.validate().await.unwrap_err();
1159        assert!(
1160            format!("{err:?}").contains("column order mismatch"),
1161            "unexpected error: {err:?}"
1162        );
1163    }
1164}