1use 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#[serde_as]
72#[derive(Deserialize, Debug, Clone, WithOptions)]
73pub struct LanceDbCommon {
74 #[serde(rename = "lancedb.uri")]
76 pub uri: String,
77
78 #[serde(rename = "lancedb.table")]
80 pub table: String,
81
82 #[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 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 #[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#[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 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 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 let conn = self.config.common.create_connection().await?;
229
230 let table = self.config.common.open_table(&conn).await?;
232
233 let lance_schema = table
235 .schema()
236 .await
237 .context("failed to get LanceDB table schema")
238 .map_err(SinkError::LanceDb)?;
239
240 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
278use 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
327pub struct LanceDbSinkWriter {
336 pub config: LanceDbConfig,
337 #[expect(dead_code)]
338 schema: Schema,
339 arrow_schema: Arc<arrow_schema::Schema>,
340 dataset_uri: String,
342 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 store_params,
402 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 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
535pub 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 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 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 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 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 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
815impl From<lancedb::Error> for SinkError {
820 fn from(value: lancedb::Error) -> Self {
821 SinkError::LanceDb(anyhow!(value))
822 }
823}
824
825#[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 let dir = tempfile::tempdir().unwrap();
845 let uri = dir.path().to_str().unwrap();
846
847 let conn = lancedb::connect(uri).execute().await.unwrap();
849
850 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 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 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 let metadata = writer.barrier(true).await.unwrap().unwrap();
921
922 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 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 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 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 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 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 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 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}