1use std::collections::VecDeque;
4use std::sync::Arc;
5
6use arrow_array::RecordBatch;
7use arrow_schema::SchemaRef;
8use async_trait::async_trait;
9#[cfg(feature = "delta-lake")]
10use std::time::Instant;
11#[cfg(feature = "delta-lake")]
12use tracing::debug;
13use tracing::info;
14#[cfg(feature = "delta-lake")]
15use tracing::warn;
16
17#[cfg(feature = "delta-lake")]
18use deltalake::DeltaTable;
19
20use crate::checkpoint::SourceCheckpoint;
21use crate::config::{ConnectorConfig, ConnectorState};
22use crate::connector::{
23 SourceBatch, SourceConnector, SourceConsistency, SourceContract, SourceTopology,
24};
25use crate::connector::{SourcePosition, SourceStart};
26use crate::error::ConnectorError;
27
28use super::delta_source_config::DeltaSourceConfig;
29#[cfg(feature = "delta-lake")]
30use super::delta_source_config::{DeltaReadMode, SchemaEvolutionAction};
31
32pub struct DeltaSource {
46 config: DeltaSourceConfig,
48 state: ConnectorState,
50 schema: Option<SchemaRef>,
52 current_version: i64,
55 #[cfg(feature = "delta-lake")]
59 inflight_version: Option<i64>,
60 #[cfg(feature = "delta-lake")]
64 known_latest_version: i64,
65 pending_batches: VecDeque<RecordBatch>,
67 records_read: u64,
69 #[cfg(feature = "delta-lake")]
71 table: Option<DeltaTable>,
72 #[cfg(feature = "delta-lake")]
76 last_version_check: Option<Instant>,
77 #[cfg(feature = "delta-lake")]
80 projection_indices: Option<Vec<Option<usize>>>,
81}
82
83impl DeltaSource {
84 #[must_use]
86 pub fn new(config: DeltaSourceConfig, _registry: Option<&prometheus::Registry>) -> Self {
87 Self {
88 config,
89 state: ConnectorState::Created,
90 schema: None,
91 current_version: -1,
92 #[cfg(feature = "delta-lake")]
93 inflight_version: None,
94 #[cfg(feature = "delta-lake")]
95 known_latest_version: -1,
96 pending_batches: VecDeque::new(),
97 records_read: 0,
98 #[cfg(feature = "delta-lake")]
99 table: None,
100 #[cfg(feature = "delta-lake")]
101 last_version_check: None,
102 #[cfg(feature = "delta-lake")]
103 projection_indices: None,
104 }
105 }
106
107 #[must_use]
109 pub fn state(&self) -> ConnectorState {
110 self.state
111 }
112
113 #[must_use]
115 pub fn current_version(&self) -> i64 {
116 self.current_version
117 }
118
119 #[must_use]
121 pub fn config(&self) -> &DeltaSourceConfig {
122 &self.config
123 }
124
125 #[cfg(feature = "delta-lake")]
127 async fn reopen_table(&mut self) -> Result<(), ConnectorError> {
128 use super::delta_io;
129
130 let table = delta_io::open_or_create_table(
131 &self.config.table_path,
132 self.config.storage_options.clone(),
133 None,
134 )
135 .await?;
136
137 self.table = Some(table);
138 Ok(())
139 }
140}
141
142#[async_trait]
143#[allow(clippy::too_many_lines)]
144impl SourceConnector for DeltaSource {
145 fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
146 Ok(SourceContract::new(
147 SourceConsistency::Ephemeral,
148 SourceTopology::Singleton,
149 ))
150 }
151
152 async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
153 let (config, position, _) = request.into_parts();
154 if let SourcePosition::Resume { attempt, .. } = position {
155 return Err(ConnectorError::ConfigurationError(format!(
156 "Delta Lake is an ephemeral source and cannot resume checkpoint attempt {attempt:?}"
157 )));
158 }
159 let config = &config;
160 self.state = ConnectorState::Initializing;
161
162 if !config.properties().is_empty() {
164 self.config = DeltaSourceConfig::from_config(config)?;
165 }
166
167 info!(
168 table_path = %self.config.table_path,
169 starting_version = ?self.config.starting_version,
170 "opening Delta Lake source connector"
171 );
172
173 #[cfg(feature = "delta-lake")]
174 {
175 use super::delta_io;
176
177 let table = delta_io::open_or_create_table(
179 &self.config.table_path,
180 self.config.storage_options.clone(),
181 None,
182 )
183 .await?;
184
185 if let Ok(schema) = delta_io::get_table_schema(&table) {
187 self.schema = Some(schema);
188 }
189
190 if let Some(start) = self.config.starting_version {
193 self.current_version = start;
194 } else {
195 self.current_version = -1;
196 }
197 let table_version = table.version().unwrap_or(0);
198
199 info!(
200 table_path = %self.config.table_path,
201 table_version,
202 current_version = self.current_version,
203 "Delta Lake source: resolved starting version"
204 );
205
206 self.table = Some(table);
207 }
208
209 #[cfg(not(feature = "delta-lake"))]
210 {
211 self.state = ConnectorState::Failed;
212 return Err(ConnectorError::ConfigurationError(
213 "Delta Lake source requires the 'delta-lake' feature to be enabled. \
214 Build with: cargo build --features delta-lake"
215 .into(),
216 ));
217 }
218
219 #[cfg(feature = "delta-lake")]
220 {
221 self.state = ConnectorState::Running;
222 info!("Delta Lake source connector opened successfully");
223 Ok(())
224 }
225 }
226
227 #[allow(unused_variables)]
228 async fn poll_batch(
229 &mut self,
230 max_records: usize,
231 ) -> Result<Option<SourceBatch>, ConnectorError> {
232 if self.state != ConnectorState::Running {
233 return Err(ConnectorError::InvalidState {
234 expected: "Running".into(),
235 actual: self.state.to_string(),
236 });
237 }
238
239 if let Some(batch) = self.pending_batches.pop_front() {
243 self.records_read += batch.num_rows() as u64;
244
245 #[cfg(feature = "delta-lake")]
246 if self.pending_batches.is_empty() {
247 if let Some(v) = self.inflight_version.take() {
248 self.current_version = v;
249 }
250 }
251
252 return Ok(Some(SourceBatch::new(batch)));
253 }
254
255 #[cfg(feature = "delta-lake")]
257 {
258 use super::delta_io;
259
260 if self.table.is_none() {
262 match self.reopen_table().await {
263 Ok(()) => {
264 info!("Delta Lake source: re-opened table after lost handle");
265 }
266 Err(e) => {
267 warn!(error = %e, "Delta Lake source: reopen failed, will retry");
268 return Ok(None);
269 }
270 }
271 }
272
273 let needs_refresh = self.known_latest_version <= self.current_version;
279 if needs_refresh {
280 if let Some(last_check) = self.last_version_check {
281 if last_check.elapsed() < self.config.poll_interval {
282 return Ok(None);
283 }
284 }
285 self.last_version_check = Some(Instant::now());
286
287 let table = self
288 .table
289 .as_mut()
290 .ok_or_else(|| ConnectorError::InvalidState {
291 expected: "table initialized".into(),
292 actual: "table not initialized".into(),
293 })?;
294 let latest_version = match delta_io::get_latest_version(table).await {
295 Ok(v) => v,
296 Err(e) => {
297 warn!(error = %e, "Delta Lake source: version check failed, will retry");
298 return Ok(None);
299 }
300 };
301 self.known_latest_version = latest_version;
302
303 if latest_version <= self.current_version {
304 return Ok(None); }
306
307 debug!(
308 current_version = self.current_version,
309 latest_version, "Delta Lake source: new version(s) available"
310 );
311 }
312
313 let target_version = match self.config.read_mode {
314 DeltaReadMode::Snapshot => self.known_latest_version,
315 DeltaReadMode::Incremental => self.current_version + 1,
316 };
317
318 let table = self
322 .table
323 .as_mut()
324 .ok_or_else(|| ConnectorError::InvalidState {
325 expected: "table initialized".into(),
326 actual: "table not initialized".into(),
327 })?;
328 let partition_filter = self.config.partition_filter.clone();
329
330 let mut use_snapshot_fallback = false;
333 if self.config.read_mode == DeltaReadMode::Incremental && target_version > 0 {
334 let log_store = table.log_store();
335 if let Ok(None) = log_store.read_commit_entry(target_version).await {
336 warn!(
337 target_version,
338 known_latest = self.known_latest_version,
339 "version unavailable, falling back to snapshot at latest"
340 );
341 use_snapshot_fallback = true;
342 }
343 }
344
345 let target_version = if use_snapshot_fallback {
348 self.known_latest_version
349 } else {
350 target_version
351 };
352
353 let (batches, fully_consumed) = if use_snapshot_fallback {
359 delta_io::read_batches_at_version(table, target_version, max_records).await?
360 } else if self.config.cdf_enabled
361 && self.config.read_mode == DeltaReadMode::Incremental
362 && target_version > 0
363 {
364 let taken_table =
367 self.table
368 .take()
369 .ok_or_else(|| ConnectorError::InvalidState {
370 expected: "table initialized".into(),
371 actual: "table not initialized".into(),
372 })?;
373 let cdf_batches =
374 delta_io::read_cdf_batches(taken_table, target_version, target_version).await?;
375
376 self.reopen_table().await?;
378
379 let mut mapped = Vec::new();
381 for batch in &cdf_batches {
382 if let Some(mapped_batch) = delta_io::map_cdf_to_changelog(batch)? {
383 mapped.push(mapped_batch);
384 }
385 }
386 (mapped, true)
387 } else {
388 match self.config.read_mode {
389 DeltaReadMode::Snapshot => {
390 delta_io::read_batches_at_version(table, target_version, max_records)
391 .await?
392 }
393 DeltaReadMode::Incremental => {
394 let (b, _) = delta_io::read_version_diff(
397 table,
398 target_version,
399 usize::MAX,
400 partition_filter.as_deref(),
401 )
402 .await?;
403 (b, true)
404 }
405 }
406 };
407
408 {
412 let table = self
413 .table
414 .as_ref()
415 .ok_or_else(|| ConnectorError::InvalidState {
416 expected: "table initialized".into(),
417 actual: "table not initialized".into(),
418 })?;
419 if let Ok(snapshot) = table.snapshot() {
420 let new_schema = snapshot.snapshot().arrow_schema();
421 if let Some(existing) = &self.schema {
422 if existing.fields() != new_schema.fields() {
423 match self.config.schema_evolution_action {
424 SchemaEvolutionAction::Warn => {
425 warn!(
426 table_path = %self.config.table_path,
427 old_fields = ?existing.fields().iter().map(|f| f.name().as_str()).collect::<Vec<_>>(),
428 new_fields = ?new_schema.fields().iter().map(|f| f.name().as_str()).collect::<Vec<_>>(),
429 "Delta Lake source: schema evolved, projecting to original"
430 );
431 let indices: Vec<Option<usize>> = existing
434 .fields()
435 .iter()
436 .map(|f| new_schema.index_of(f.name()).ok())
437 .collect();
438 self.projection_indices = Some(indices);
439 }
442 SchemaEvolutionAction::Error => {
443 return Err(ConnectorError::SchemaMismatch(format!(
444 "schema evolved at version {target_version}"
445 )));
446 }
447 }
448 }
449 } else {
450 self.schema = Some(new_schema);
451 }
452 }
453 }
454
455 for batch in batches {
459 if batch.num_rows() == 0 {
460 continue;
461 }
462 let batch = if let Some(ref indices) = self.projection_indices {
464 let original_schema = self.schema.as_ref().unwrap();
465 let num_rows = batch.num_rows();
466 let columns: Vec<Arc<dyn arrow_array::Array>> = indices
467 .iter()
468 .zip(original_schema.fields())
469 .map(|(idx, field)| match idx {
470 Some(i) => batch.column(*i).clone(),
471 None => arrow_array::new_null_array(field.data_type(), num_rows),
472 })
473 .collect();
474 RecordBatch::try_new(original_schema.clone(), columns).map_err(|e| {
475 ConnectorError::ReadError(format!(
476 "failed to project batch to original schema: {e}"
477 ))
478 })?
479 } else {
480 batch
481 };
482 self.pending_batches.push_back(batch);
483 }
484
485 if !fully_consumed {
486 } else if self.pending_batches.is_empty() {
491 self.current_version = target_version;
493 } else {
494 self.inflight_version = Some(target_version);
496 }
497
498 if let Some(batch) = self.pending_batches.pop_front() {
499 self.records_read += batch.num_rows() as u64;
500
501 if self.pending_batches.is_empty() {
503 if let Some(v) = self.inflight_version.take() {
504 self.current_version = v;
505 }
506 }
507
508 return Ok(Some(SourceBatch::new(batch)));
509 }
510 }
511
512 Ok(None)
513 }
514
515 fn schema(&self) -> SchemaRef {
516 self.schema
517 .clone()
518 .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()))
519 }
520
521 fn checkpoint(&self) -> SourceCheckpoint {
522 let mut cp = SourceCheckpoint::new();
523 cp.set_offset("delta_version", self.current_version.to_string());
524 cp.set_offset("read_mode", self.config.read_mode.to_string());
525 cp
526 }
527
528 async fn close(&mut self) -> Result<(), ConnectorError> {
529 info!("closing Delta Lake source connector");
530
531 #[cfg(feature = "delta-lake")]
532 {
533 self.table = None;
534 }
535
536 self.pending_batches.clear();
537 self.state = ConnectorState::Closed;
538
539 info!(
540 table_path = %self.config.table_path,
541 current_version = self.current_version,
542 records_read = self.records_read,
543 "Delta Lake source connector closed"
544 );
545
546 Ok(())
547 }
548}
549
550impl std::fmt::Debug for DeltaSource {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 f.debug_struct("DeltaSource")
553 .field("state", &self.state)
554 .field("table_path", &self.config.table_path)
555 .field("read_mode", &self.config.read_mode)
556 .field("current_version", &self.current_version)
557 .field("pending_batches", &self.pending_batches.len())
558 .field("records_read", &self.records_read)
559 .finish_non_exhaustive()
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use arrow_array::{Float64Array, Int64Array, StringArray};
567 use arrow_schema::{DataType, Field, Schema};
568
569 fn test_config() -> DeltaSourceConfig {
570 DeltaSourceConfig::new("/tmp/delta_source_test")
571 }
572
573 fn test_schema() -> SchemaRef {
574 Arc::new(Schema::new(vec![
575 Field::new("id", DataType::Int64, false),
576 Field::new("name", DataType::Utf8, true),
577 Field::new("value", DataType::Float64, true),
578 ]))
579 }
580
581 #[allow(clippy::cast_precision_loss)]
582 fn test_batch(n: usize) -> RecordBatch {
583 let ids: Vec<i64> = (0..n as i64).collect();
584 let names: Vec<&str> = (0..n).map(|_| "test").collect();
585 let values: Vec<f64> = (0..n).map(|i| i as f64 * 1.5).collect();
586
587 RecordBatch::try_new(
588 test_schema(),
589 vec![
590 Arc::new(Int64Array::from(ids)),
591 Arc::new(StringArray::from(names)),
592 Arc::new(Float64Array::from(values)),
593 ],
594 )
595 .unwrap()
596 }
597
598 #[test]
599 fn test_new_defaults() {
600 let source = DeltaSource::new(test_config(), None);
601 assert_eq!(source.state(), ConnectorState::Created);
602 assert_eq!(source.current_version(), -1);
603 assert!(source.schema.is_none());
604 }
605
606 #[test]
607 fn test_checkpoint_roundtrip() {
608 let mut source = DeltaSource::new(test_config(), None);
609 source.current_version = 42;
610
611 let cp = source.checkpoint();
612 assert_eq!(cp.get_offset("delta_version"), Some("42"));
613 }
614
615 #[tokio::test]
616 async fn resume_fails_before_opening_the_ephemeral_source() {
617 let mut source = DeltaSource::new(test_config(), None);
618 let error = source
619 .start(
620 SourceStart::new(
621 ConnectorConfig::new("delta-lake"),
622 SourcePosition::Resume {
623 attempt: laminar_core::state::CheckpointAttempt::canonical(11),
624 checkpoint: SourceCheckpoint::new(),
625 },
626 crate::connector::DeliveryGuarantee::BestEffort,
627 )
628 .unwrap(),
629 )
630 .await
631 .expect_err("ephemeral Delta source must reject recovery");
632 assert!(error.to_string().contains("ephemeral"));
633 assert_eq!(source.state(), ConnectorState::Created);
634 }
635
636 #[test]
637 fn test_schema_empty_when_none() {
638 let source = DeltaSource::new(test_config(), None);
639 let schema = source.schema();
640 assert_eq!(schema.fields().len(), 0);
641 }
642
643 #[tokio::test]
644 async fn test_poll_not_running() {
645 let mut source = DeltaSource::new(test_config(), None);
646 let result = source.poll_batch(100).await;
648 assert!(result.is_err());
649 }
650
651 #[tokio::test]
652 async fn test_poll_returns_buffered_batches() {
653 let mut source = DeltaSource::new(test_config(), None);
654 source.state = ConnectorState::Running;
655
656 source.pending_batches.push_back(test_batch(5));
658 source.pending_batches.push_back(test_batch(3));
659
660 let batch1 = source.poll_batch(100).await.unwrap();
661 assert!(batch1.is_some());
662 assert_eq!(batch1.unwrap().records.num_rows(), 5);
663
664 let batch2 = source.poll_batch(100).await.unwrap();
665 assert!(batch2.is_some());
666 assert_eq!(batch2.unwrap().records.num_rows(), 3);
667
668 assert_eq!(source.records_read, 8);
669 }
670
671 #[tokio::test]
675 async fn test_poll_batch_returns_buffered_incrementally() {
676 let mut source = DeltaSource::new(test_config(), None);
677 source.state = ConnectorState::Running;
678
679 for _ in 0..10 {
681 source.pending_batches.push_back(test_batch(100));
682 }
683
684 let batch = source.poll_batch(50).await.unwrap();
686 assert!(batch.is_some());
687 assert_eq!(batch.unwrap().records.num_rows(), 100);
688 assert_eq!(source.pending_batches.len(), 9);
690 }
691
692 #[tokio::test]
696 async fn test_version_deferred_until_buffer_drained() {
697 let mut source = DeltaSource::new(test_config(), None);
698 source.state = ConnectorState::Running;
699 source.current_version = 5;
700
701 source.pending_batches.push_back(test_batch(10));
707 source.pending_batches.push_back(test_batch(10));
708 source.pending_batches.push_back(test_batch(10));
709
710 let b1 = source.poll_batch(100).await.unwrap();
713 assert!(b1.is_some());
714 assert_eq!(source.pending_batches.len(), 2);
715
716 let b2 = source.poll_batch(100).await.unwrap();
717 assert!(b2.is_some());
718 assert_eq!(source.pending_batches.len(), 1);
719
720 let b3 = source.poll_batch(100).await.unwrap();
721 assert!(b3.is_some());
722 assert!(source.pending_batches.is_empty());
723 assert_eq!(source.records_read, 30);
724 }
725
726 #[test]
729 fn test_poll_interval_is_stored() {
730 let mut config = test_config();
731 config.poll_interval = std::time::Duration::from_millis(500);
732 let source = DeltaSource::new(config, None);
733 assert_eq!(
734 source.config().poll_interval,
735 std::time::Duration::from_millis(500)
736 );
737 }
738
739 #[test]
740 fn test_debug_output() {
741 let source = DeltaSource::new(test_config(), None);
742 let debug = format!("{source:?}");
743 assert!(debug.contains("DeltaSource"));
744 assert!(debug.contains("/tmp/delta_source_test"));
745 }
746
747 #[tokio::test]
748 async fn test_close() {
749 let mut source = DeltaSource::new(test_config(), None);
750 source.state = ConnectorState::Running;
751 source.pending_batches.push_back(test_batch(5));
752
753 source.close().await.unwrap();
754 assert_eq!(source.state(), ConnectorState::Closed);
755 assert!(source.pending_batches.is_empty());
756 }
757
758 #[cfg(not(feature = "delta-lake"))]
760 #[tokio::test]
761 async fn test_open_requires_feature() {
762 let mut source = DeltaSource::new(test_config(), None);
763 let connector_config = crate::config::ConnectorConfig::new("delta-lake");
764 let result = source
765 .start(
766 SourceStart::new(
767 connector_config,
768 SourcePosition::Initial,
769 crate::connector::DeliveryGuarantee::BestEffort,
770 )
771 .unwrap(),
772 )
773 .await;
774 assert!(result.is_err());
775 let err = result.unwrap_err().to_string();
776 assert!(err.contains("delta-lake"), "error: {err}");
777 }
778}