Skip to main content

laminar_connectors/lakehouse/
delta_source.rs

1//! Delta Lake source connector.
2
3use 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
32/// Delta Lake source connector.
33///
34/// Reads Arrow `RecordBatch` data from Delta Lake tables by polling for
35/// new table versions. Supports both incremental (changes-only) and
36/// snapshot (full re-read) modes.
37///
38/// # Lifecycle
39///
40/// ```text
41/// new() -> start() -> [poll_batch()]* -> close()
42///                           |
43///                      checkpoint()
44/// ```
45pub struct DeltaSource {
46    /// Source configuration.
47    config: DeltaSourceConfig,
48    /// Connector lifecycle state.
49    state: ConnectorState,
50    /// Arrow schema (set from table metadata on start).
51    schema: Option<SchemaRef>,
52    /// Current Delta Lake version cursor — the last *fully consumed* version.
53    /// Only advanced after all buffered batches for a version are drained.
54    current_version: i64,
55    /// The version currently being drained. While `pending_batches` is
56    /// non-empty this holds the version they came from. Once drained,
57    /// `current_version` is advanced to this value and the field is cleared.
58    #[cfg(feature = "delta-lake")]
59    inflight_version: Option<i64>,
60    /// The latest version known at the table. Used in incremental mode to
61    /// walk versions one-by-one without re-calling `get_latest_version` for
62    /// each step.
63    #[cfg(feature = "delta-lake")]
64    known_latest_version: i64,
65    /// Buffered batches from the last version load.
66    pending_batches: VecDeque<RecordBatch>,
67    /// Total records read so far.
68    records_read: u64,
69    /// Delta Lake table handle.
70    #[cfg(feature = "delta-lake")]
71    table: Option<DeltaTable>,
72    /// Last time we checked for new Delta versions. Used to throttle
73    /// `get_latest_version()` calls to `poll_interval` instead of
74    /// hammering every source-adapter tick (10ms).
75    #[cfg(feature = "delta-lake")]
76    last_version_check: Option<Instant>,
77    /// Per-field projection: `Some(idx)` = take column idx from new batch,
78    /// `None` = emit null column. Aligned with `self.schema.fields()`.
79    #[cfg(feature = "delta-lake")]
80    projection_indices: Option<Vec<Option<usize>>>,
81}
82
83impl DeltaSource {
84    /// Creates a new Delta Lake source with the given configuration.
85    #[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    /// Returns the current connector state.
108    #[must_use]
109    pub fn state(&self) -> ConnectorState {
110        self.state
111    }
112
113    /// Returns the current Delta Lake version cursor.
114    #[must_use]
115    pub fn current_version(&self) -> i64 {
116        self.current_version
117    }
118
119    /// Returns the source configuration.
120    #[must_use]
121    pub fn config(&self) -> &DeltaSourceConfig {
122        &self.config
123    }
124
125    /// Re-opens the Delta Lake table (e.g., after a connection failure).
126    #[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        // Re-parse config if properties provided.
163        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            // Open the existing table (source requires the table to exist).
178            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            // Read schema from table.
186            if let Ok(schema) = delta_io::get_table_schema(&table) {
187                self.schema = Some(schema);
188            }
189
190            // Start from -1 (or explicit starting_version) and let
191            // poll_batch() walk versions incrementally with bounded reads.
192            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        // Return buffered batches first. When the buffer drains
240        // completely, advance current_version to the inflight version
241        // so that checkpoint() reports the fully-consumed position.
242        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        // Check for new versions, throttled by poll_interval.
256        #[cfg(feature = "delta-lake")]
257        {
258            use super::delta_io;
259
260            // Recover from lost table handle (e.g., connection failure).
261            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            // Throttle version checks: skip if less than poll_interval has
274            // elapsed since the last check. This prevents hammering
275            // get_latest_version() on every source-adapter tick (10ms).
276            // In incremental mode, skip the throttle if we already know
277            // there are more versions to process (catch-up).
278            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); // No new data
305                }
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            // Read data first. Both read_batches_at_version and
319            // read_version_diff call load_version(target_version) internally,
320            // so the table's snapshot will be at target_version after this.
321            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            // Version-gap detection: if the target commit was cleaned up,
331            // skip ahead to a snapshot at the latest available version.
332            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            // On gap fallback, override target_version so inflight_version
346            // tracks the snapshot version, not the missing one.
347            let target_version = if use_snapshot_fallback {
348                self.known_latest_version
349            } else {
350                target_version
351            };
352
353            // Incremental reads (read_version_diff, CDF) always consume the
354            // full version — each version's diff is O(new_files), not
355            // O(table_size), so unbounded reads are safe. This avoids the
356            // re-read-from-start problem when max_records truncates a version.
357            // Snapshot reads stay bounded by max_records (can be table-sized).
358            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                // CDF mode: scan_cdf() consumes the DeltaTable. Take it,
365                // read CDF batches, then re-open the table handle.
366                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                // Re-open table since scan_cdf consumed it.
377                self.reopen_table().await?;
378
379                // Map CDF _change_type to LaminarDB _op.
380                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                        // Read full version diff — each version is one
395                        // commit's worth of files, safe to read unbounded.
396                        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            // Schema evolution detection: extract schema from the snapshot
409            // that read_version_diff/read_batches_at_version already loaded.
410            // This avoids a redundant load_version call.
411            {
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                                    // Map each original field to its index in the new
432                                    // schema, or None if the field was removed.
433                                    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                                    // Do NOT update self.schema — keep the original
440                                    // for downstream stability.
441                                }
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            // Buffer all batches. Do NOT advance current_version yet —
456            // it is only safe to checkpoint this version after the
457            // buffer is fully drained. Store it as inflight_version.
458            for batch in batches {
459                if batch.num_rows() == 0 {
460                    continue;
461                }
462                // Apply schema projection if schema evolution was detected.
463                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                // Snapshot mode: max_records truncated the version.
487                // Don't advance — next poll re-reads the same version.
488                // (Incremental mode always reads full versions, so
489                // fully_consumed is always true there.)
490            } else if self.pending_batches.is_empty() {
491                // Version fully consumed with no data rows (metadata-only).
492                self.current_version = target_version;
493            } else {
494                // Version fully consumed, batches buffered. Advance after drain.
495                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                // Single-batch version: buffer is already empty, advance now.
502                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        // state is Created, not Running
647        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        // Manually buffer some batches.
657        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    /// D002/D003: Verify `max_records` bounds the pending buffer.
672    /// Without the delta-lake feature, `poll_batch` returns buffered data
673    /// incrementally; with the feature, `read_batches_at_version` applies LIMIT.
674    #[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        // Simulate what read_batches_at_version produces: many small batches
680        for _ in 0..10 {
681            source.pending_batches.push_back(test_batch(100));
682        }
683
684        // Each poll_batch returns exactly one buffered batch
685        let batch = source.poll_batch(50).await.unwrap();
686        assert!(batch.is_some());
687        assert_eq!(batch.unwrap().records.num_rows(), 100);
688        // 9 remaining
689        assert_eq!(source.pending_batches.len(), 9);
690    }
691
692    /// Version is only advanced after the inflight buffer is fully drained.
693    /// With multiple buffered batches, `current_version` stays at the old value
694    /// until the last batch is consumed, then jumps to the target version.
695    #[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        // Simulate: read_batches_at_version loaded version 42 with 3 batches.
702        // In production the delta-lake cfg block sets inflight_version; here
703        // we set it manually to test the drain logic (which is not cfg-gated
704        // inside the pop_front path above — it is, so we test via the
705        // non-feature path by just checking the pending_batches drain).
706        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        // Without delta-lake feature, inflight_version doesn't exist, so
711        // current_version won't auto-advance. Verify the buffer drains.
712        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    /// D004: `poll_interval` is parsed and stored in config.
727    /// The field is used by the delta-lake feature to throttle version checks.
728    #[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    /// D020: Source `start()` must error without delta-lake feature.
759    #[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}