Skip to main content

laminar_connectors/lakehouse/delta_source/
mod.rs

1//! Delta Lake change-data-feed and snapshot source.
2//!
3//! Rows are read through the Delta kernel in replay units — one file of a pinned snapshot, or
4//! one commit of the change data feed — and the source checkpoints only between units. Operator
5//! state recovered with a cursor therefore holds every earlier unit completely and nothing of the
6//! units replay delivers again.
7
8mod cursor;
9#[cfg(feature = "delta-lake")]
10mod reader;
11#[cfg(feature = "delta-lake")]
12mod reader_contract;
13
14use std::sync::Arc;
15
16#[cfg(feature = "delta-lake")]
17use arrow_array::{BinaryArray, RecordBatch, UInt32Array};
18use arrow_schema::SchemaRef;
19use async_trait::async_trait;
20#[cfg(feature = "delta-lake")]
21use std::time::Instant;
22use tracing::info;
23
24#[cfg(feature = "delta-lake")]
25use deltalake::DeltaTable;
26
27use crate::checkpoint::SourceCheckpoint;
28use crate::config::{ConnectorConfig, ConnectorState};
29use crate::connector::{
30    ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceCheckpointUnavailablePolicy,
31    SourceConnector, SourceConsistency, SourceContract, SourceInputMode, SourcePosition,
32    SourceRowPositionCapability, SourceStart, SourceTopology,
33};
34use crate::error::ConnectorError;
35
36use super::delta_source_config::DeltaSourceConfig;
37#[cfg(feature = "delta-lake")]
38use super::delta_source_config::{DeltaReadMode, DeltaStartingVersion};
39use cursor::DeltaCursor;
40#[cfg(feature = "delta-lake")]
41use cursor::DeltaPhase;
42
43/// Commits planned by one change-feed scan; bounds the log work behind one kernel read.
44#[cfg(feature = "delta-lake")]
45const MAX_COMMITS_PER_SCAN: u64 = 64;
46
47const PARTIAL_UNIT_ERROR: &str = "[LDB-DELTA-PARTIAL-UNIT] Delta read failed after part of a \
48                                  commit or snapshot file was emitted; recovery from the last \
49                                  durable cursor is required";
50
51/// Delta Lake source connector.
52///
53/// Emits a full changelog (`__weight` = +1 for inserts and update post-images, -1 for deletes
54/// and update pre-images) from either a pinned snapshot followed by the change data feed, the
55/// change data feed from an explicit version, or one snapshot that then completes.
56///
57/// # Lifecycle
58///
59/// ```text
60/// new() -> start() -> [poll_batch()]* -> close()
61///                           |
62///                      checkpoint()
63/// ```
64pub struct DeltaSource {
65    config: DeltaSourceConfig,
66    state: ConnectorState,
67    /// Arrow schema emitted to the pipeline, set from the bound reader contract on start.
68    schema: Option<SchemaRef>,
69    #[cfg(feature = "delta-lake")]
70    schema_binding: Option<crate::schema::resolution::SchemaBinding>,
71    #[cfg(feature = "delta-lake")]
72    task_owner: ConnectorTaskOwner,
73    task_tracker: ConnectorTaskTracker,
74    /// Cursor after the last complete replay unit; the only position ever checkpointed.
75    cursor: Option<DeltaCursor>,
76    /// Replay unit whose rows are partly emitted.
77    #[cfg(feature = "delta-lake")]
78    open_unit: Option<OpenUnit>,
79    #[cfg(feature = "delta-lake")]
80    reader: Option<Reader>,
81    records_read: u64,
82}
83
84#[cfg(feature = "delta-lake")]
85#[derive(Debug, Clone, Copy)]
86struct OpenUnit {
87    unit: reader::UnitKey,
88    rows_emitted: u32,
89}
90
91#[cfg(feature = "delta-lake")]
92struct Pending {
93    batch: RecordBatch,
94    unit: reader::UnitKey,
95    completes: Option<DeltaPhase>,
96    _permit: tokio::sync::OwnedSemaphorePermit,
97}
98
99/// Open table and in-flight read of a started source.
100#[cfg(feature = "delta-lake")]
101struct Reader {
102    /// Metadata-only handle used to discover new commits and confirm the table incarnation.
103    table: DeltaTable,
104    /// Catalog resolution in use, including vended credentials and their expiry.
105    resolved: super::delta_io::ResolvedCatalogTable,
106    context: reader::ReadContext,
107    scan: Option<reader::ScanTask>,
108    pending: Option<Pending>,
109    last_version_check: Option<Instant>,
110    /// Latest committed version the last check observed.
111    known_latest: Option<u64>,
112}
113
114impl DeltaSource {
115    /// Creates a new Delta Lake source with the given configuration.
116    #[must_use]
117    pub fn new(config: DeltaSourceConfig, _registry: Option<&prometheus::Registry>) -> Self {
118        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
119        #[cfg(not(feature = "delta-lake"))]
120        drop(task_owner);
121        Self {
122            config,
123            state: ConnectorState::Created,
124            schema: None,
125            #[cfg(feature = "delta-lake")]
126            schema_binding: None,
127            #[cfg(feature = "delta-lake")]
128            task_owner,
129            task_tracker,
130            cursor: None,
131            #[cfg(feature = "delta-lake")]
132            open_unit: None,
133            #[cfg(feature = "delta-lake")]
134            reader: None,
135            records_read: 0,
136        }
137    }
138
139    /// Returns the current connector state.
140    #[must_use]
141    pub fn state(&self) -> ConnectorState {
142        self.state
143    }
144
145    /// Returns the source configuration.
146    #[must_use]
147    pub fn config(&self) -> &DeltaSourceConfig {
148        &self.config
149    }
150}
151
152#[cfg(feature = "delta-lake")]
153impl DeltaSource {
154    async fn open(
155        &mut self,
156        config: &ConnectorConfig,
157        recovered: Option<DeltaCursor>,
158    ) -> Result<(), ConnectorError> {
159        use super::delta_io;
160
161        let resolved = self.resolve_table().await?;
162        let resolved_storage = crate::storage::StorageCredentialResolver::resolve(
163            &resolved.location,
164            &resolved.storage_options,
165        );
166        info!(
167            operation_class = "connector-open",
168            storage_provider = %resolved_storage.provider,
169            storage_endpoint_class = %resolved_storage.endpoint_class(),
170            storage_auth_source = %resolved_storage.auth_source,
171            read_mode = ?self.config.read_mode,
172            starting_version = ?self.config.starting_version,
173            resumed = recovered.is_some(),
174            "opening Delta Lake source connector"
175        );
176        let table =
177            delta_io::open_table_metadata(&resolved.location, resolved.storage_options.clone())
178                .await?;
179        self.install_reader_contract(config, &table)?;
180        let snapshot = table.snapshot().map_err(|error| {
181            delta_io::classify_delta_metadata_error("read Delta table metadata", &error)
182        })?;
183        let table_id = snapshot.metadata().id().to_string();
184        self.check_read_features(snapshot.metadata().configuration())?;
185        let latest = snapshot.version();
186        let cursor = match recovered {
187            Some(cursor) => self.validate_recovered(cursor, &table_id)?,
188            None => self.initial_cursor(table_id, latest)?,
189        };
190
191        let log_store = table.log_store();
192        let mut table_root = log_store.root_url().clone();
193        if !table_root.path().ends_with('/') {
194            table_root.set_path(&format!("{}/", table_root.path()));
195        }
196        let output_schema = self
197            .schema
198            .clone()
199            .ok_or_else(|| ConnectorError::InvalidState {
200                expected: "bound Delta reader schema".into(),
201                actual: "schema missing".into(),
202            })?;
203        let context = reader::ReadContext {
204            engine: log_store.engine(None),
205            table_root,
206            output_schema,
207            budget: Arc::new(tokio::sync::Semaphore::new(
208                reader::MAX_QUEUED_BYTES as usize,
209            )),
210            renew_at: delta_io::credentials_refresh_at(resolved.credentials_expire_at),
211        };
212        info!(
213            latest_version = latest,
214            cursor = ?cursor.phase,
215            "Delta Lake source: installed read position"
216        );
217        self.cursor = Some(cursor);
218        self.reader = Some(Reader {
219            table,
220            resolved,
221            context,
222            scan: None,
223            pending: None,
224            last_version_check: None,
225            known_latest: None,
226        });
227        Ok(())
228    }
229
230    /// Resolve the table through its catalog for reading. Environment credential fallbacks are
231    /// resolved again each time rather than retained from the first start.
232    async fn resolve_table(&self) -> Result<super::delta_io::ResolvedCatalogTable, ConnectorError> {
233        let mut resolved = super::delta_io::resolve_catalog_options(
234            &self.config.catalog_type,
235            self.config.catalog_database.as_deref(),
236            self.config.catalog_name.as_deref(),
237            self.config.catalog_schema.as_deref(),
238            &self.config.table_path,
239            &self.config.stable_storage_options(),
240            super::delta_io::CatalogAccess::Read,
241        )
242        .await?;
243        resolved.storage_options = crate::storage::StorageCredentialResolver::resolve(
244            &resolved.location,
245            &resolved.storage_options,
246        )
247        .options;
248        Ok(resolved)
249    }
250
251    /// Replace expiring vended credentials before the next read. Only the storage access
252    /// changes; the table must keep its location and incarnation.
253    async fn renew_credentials(&mut self) -> Result<(), ConnectorError> {
254        let resolved = self.resolve_table().await?;
255        let table_id = self.cursor()?.table_id.clone();
256        let reader = self.reader_mut()?;
257        if resolved.location != reader.resolved.location {
258            return Err(ConnectorError::ConfigurationError(
259                "the catalog moved the Delta table to another storage location; reset the source"
260                    .into(),
261            ));
262        }
263        let table = super::delta_io::open_table_metadata(
264            &resolved.location,
265            resolved.storage_options.clone(),
266        )
267        .await?;
268        ensure_same_table(&table, &table_id)?;
269        reader.context.engine = table.log_store().engine(None);
270        reader.context.renew_at =
271            super::delta_io::credentials_refresh_at(resolved.credentials_expire_at);
272        reader.table = table;
273        reader.resolved = resolved;
274        info!("Delta Lake source: renewed vended storage credentials");
275        Ok(())
276    }
277
278    /// The change data feed needs CDF enabled and, in the kernel, no column mapping. Snapshot
279    /// reads support both column mapping and deletion vectors.
280    fn check_read_features(
281        &self,
282        configuration: &std::collections::HashMap<String, String>,
283    ) -> Result<(), ConnectorError> {
284        if self.config.read_mode == DeltaReadMode::Snapshot {
285            return Ok(());
286        }
287        let enabled = configuration
288            .get("delta.enableChangeDataFeed")
289            .is_some_and(|value| value.eq_ignore_ascii_case("true"));
290        if !enabled {
291            return Err(ConnectorError::ConfigurationError(
292                "Delta read.mode=changes requires the table property \
293                 delta.enableChangeDataFeed=true; enable it or use read.mode=snapshot"
294                    .into(),
295            ));
296        }
297        if configuration
298            .get("delta.columnMapping.mode")
299            .is_some_and(|mode| !mode.eq_ignore_ascii_case("none"))
300        {
301            return Err(ConnectorError::ConfigurationError(
302                "the Delta kernel change data feed does not support column-mapped tables; use \
303                 read.mode=snapshot"
304                    .into(),
305            ));
306        }
307        Ok(())
308    }
309
310    fn initial_cursor(&self, table_id: String, latest: u64) -> Result<DeltaCursor, ConnectorError> {
311        let after_latest = latest
312            .checked_add(1)
313            .ok_or_else(|| ConnectorError::ReadError("Delta version overflowed".into()))?;
314        let phase = match (self.config.read_mode, self.config.starting_version) {
315            (DeltaReadMode::Snapshot, _)
316            | (DeltaReadMode::Changes, DeltaStartingVersion::Snapshot) => DeltaPhase::Snapshot {
317                version: latest,
318                files_done: 0,
319                last_file: None,
320            },
321            (DeltaReadMode::Changes, DeltaStartingVersion::Latest) => DeltaPhase::Changes {
322                next_version: after_latest,
323            },
324            (DeltaReadMode::Changes, DeltaStartingVersion::Version(version)) => {
325                if version > after_latest {
326                    return Err(ConnectorError::ConfigurationError(format!(
327                        "starting.version {version} is beyond the table's next version \
328                         {after_latest}"
329                    )));
330                }
331                DeltaPhase::Changes {
332                    next_version: version,
333                }
334            }
335        };
336        Ok(DeltaCursor { table_id, phase })
337    }
338
339    fn validate_recovered(
340        &self,
341        cursor: DeltaCursor,
342        table_id: &str,
343    ) -> Result<DeltaCursor, ConnectorError> {
344        if cursor.table_id != table_id {
345            return Err(ConnectorError::ConfigurationError(
346                "the Delta table was replaced since this checkpoint (its table id changed); \
347                 the old cursor is not valid for the new table — reset the source"
348                    .into(),
349            ));
350        }
351        let compatible = match (&cursor.phase, self.config.read_mode) {
352            (DeltaPhase::Snapshot { .. }, _)
353            | (DeltaPhase::Changes { .. }, DeltaReadMode::Changes)
354            | (DeltaPhase::Complete { .. }, DeltaReadMode::Snapshot) => true,
355            (DeltaPhase::Changes { .. }, DeltaReadMode::Snapshot)
356            | (DeltaPhase::Complete { .. }, DeltaReadMode::Changes) => false,
357        };
358        if !compatible {
359            return Err(ConnectorError::ConfigurationError(format!(
360                "checkpointed Delta position {:?} does not belong to read.mode={:?}",
361                cursor.phase, self.config.read_mode
362            )));
363        }
364        Ok(cursor)
365    }
366
367    fn after_snapshot(&self, version: u64) -> Result<DeltaPhase, ConnectorError> {
368        Ok(match self.config.read_mode {
369            DeltaReadMode::Snapshot => DeltaPhase::Complete { version },
370            DeltaReadMode::Changes => DeltaPhase::Changes {
371                next_version: version
372                    .checked_add(1)
373                    .ok_or_else(|| ConnectorError::ReadError("Delta version overflowed".into()))?,
374            },
375        })
376    }
377
378    fn reader_mut(&mut self) -> Result<&mut Reader, ConnectorError> {
379        self.reader
380            .as_mut()
381            .ok_or_else(|| ConnectorError::InvalidState {
382                expected: "started Delta source".into(),
383                actual: "reader not open".into(),
384            })
385    }
386
387    fn cursor(&self) -> Result<&DeltaCursor, ConnectorError> {
388        self.cursor
389            .as_ref()
390            .ok_or_else(|| ConnectorError::InvalidState {
391                expected: "installed Delta cursor".into(),
392                actual: "cursor missing".into(),
393            })
394    }
395
396    /// Emit at most `max_records` rows of the received batch. Every fallible step precedes the
397    /// cursor and unit bookkeeping, so a failed emission leaves the checkpointable state intact.
398    fn emit_pending(&mut self, max_records: usize) -> Result<Option<SourceBatch>, ConnectorError> {
399        let open_unit = self.open_unit;
400        let Some(pending) = self.reader_mut()?.pending.as_ref() else {
401            return Ok(None);
402        };
403        let first_row = match open_unit {
404            Some(open) if open.unit == pending.unit => open.rows_emitted,
405            Some(_) => {
406                return Err(ConnectorError::Internal(
407                    "Delta reader started a replay unit before ending the previous one".into(),
408                ))
409            }
410            None => 0,
411        };
412        let rows = pending.batch.num_rows();
413        let take = rows.min(max_records);
414        let emitted = positioned(pending.batch.slice(0, take), pending.unit, first_row)?;
415        let rows_emitted = u32::try_from(take)
416            .ok()
417            .and_then(|take| first_row.checked_add(take))
418            .ok_or_else(row_position_overflow)?;
419        let Some(mut pending) = self.reader_mut()?.pending.take() else {
420            return Err(ConnectorError::Internal(
421                "Delta pending batch vanished during emission".into(),
422            ));
423        };
424
425        if take < rows {
426            pending.batch = pending.batch.slice(take, rows - take);
427            self.open_unit = Some(OpenUnit {
428                unit: pending.unit,
429                rows_emitted,
430            });
431            self.reader_mut()?.pending = Some(pending);
432        } else if let Some(phase) = pending.completes.take() {
433            self.complete_unit(phase)?;
434        } else {
435            self.open_unit = Some(OpenUnit {
436                unit: pending.unit,
437                rows_emitted,
438            });
439        }
440        self.records_read = self.records_read.saturating_add(take as u64);
441        Ok(Some(emitted.with_checkpoint(self.checkpoint())))
442    }
443
444    fn complete_unit(&mut self, phase: DeltaPhase) -> Result<(), ConnectorError> {
445        let cursor = self
446            .cursor
447            .as_mut()
448            .ok_or_else(|| ConnectorError::InvalidState {
449                expected: "installed Delta cursor".into(),
450                actual: "cursor missing".into(),
451            })?;
452        cursor.phase = phase;
453        self.open_unit = None;
454        Ok(())
455    }
456
457    /// The next reader message, or `None` when no read is running. A failure after a unit's
458    /// rows were partly emitted retires this generation; earlier failures may be retried.
459    async fn next_output(&mut self) -> Result<Option<reader::ScanOutput>, ConnectorError> {
460        let reader = self.reader_mut()?;
461        let Some(scan) = reader.scan.as_mut() else {
462            return Ok(None);
463        };
464        let received = scan.receiver.recv().await;
465        if !matches!(received, Some(Ok(_))) {
466            reader.scan = None;
467        }
468        match received {
469            Some(Ok(output)) => Ok(Some(output)),
470            Some(Err(error)) => Err(self.fail_read(error)),
471            None if self.open_unit.is_some() => Err(self.fail_read(ConnectorError::ReadError(
472                "Delta reader stopped inside a replay unit".into(),
473            ))),
474            None => Ok(None),
475        }
476    }
477
478    fn fail_read(&mut self, error: ConnectorError) -> ConnectorError {
479        if self.open_unit.is_none() {
480            return error;
481        }
482        self.state = ConnectorState::Failed;
483        ConnectorError::TransactionError(format!("{PARTIAL_UNIT_ERROR}: {error}"))
484    }
485
486    /// Start reading the unit(s) after the cursor. Returns `false` when there is nothing to read.
487    async fn start_next_scan(&mut self) -> Result<bool, ConnectorError> {
488        let phase = self.cursor()?.phase.clone();
489        if !matches!(phase, DeltaPhase::Complete { .. })
490            && super::delta_io::credentials_need_refresh(
491                self.reader_mut()?.resolved.credentials_expire_at,
492            )
493        {
494            self.renew_credentials().await?;
495        }
496        let guard = self.task_owner.track().ok_or(ConnectorError::Closed)?;
497        match phase {
498            DeltaPhase::Complete { .. } => Ok(false),
499            DeltaPhase::Snapshot {
500                version,
501                files_done,
502                last_file,
503            } => {
504                let after = self.after_snapshot(version)?;
505                let reader = self.reader_mut()?;
506                reader.scan = Some(reader::spawn_snapshot(
507                    reader.context.clone(),
508                    guard,
509                    version,
510                    files_done,
511                    last_file,
512                    after,
513                ));
514                Ok(true)
515            }
516            DeltaPhase::Changes { next_version } => {
517                let poll_interval = self.config.poll_interval;
518                let reader = self.reader_mut()?;
519                // A scan reads a bounded slice of the log, so commits already seen are read
520                // without waiting for the next poll.
521                let backlog = reader
522                    .known_latest
523                    .is_some_and(|known| next_version <= known);
524                if !backlog
525                    && reader
526                        .last_version_check
527                        .is_some_and(|checked| checked.elapsed() < poll_interval)
528                {
529                    return Ok(false);
530                }
531                let latest = self.refresh_table(next_version).await?;
532                self.reader_mut()?.known_latest = Some(latest);
533                if latest < next_version {
534                    return Ok(false);
535                }
536                let end = next_version
537                    .saturating_add(MAX_COMMITS_PER_SCAN - 1)
538                    .min(latest);
539                let reader = self.reader_mut()?;
540                reader.scan = Some(reader::spawn_changes(
541                    reader.context.clone(),
542                    guard,
543                    next_version,
544                    end,
545                ));
546                Ok(true)
547            }
548        }
549    }
550
551    /// Discover the latest committed version and, when it moved, confirm the table is the same
552    /// incarnation with the bound schema before any of its new commits are read.
553    async fn refresh_table(&mut self, next_version: u64) -> Result<u64, ConnectorError> {
554        use super::delta_io;
555
556        let table_id = self.cursor()?.table_id.clone();
557        let reader = self.reader_mut()?;
558        reader.last_version_check = Some(Instant::now());
559        // The cheap incremental listing only answers "is there anything new"; it cannot see a
560        // table recreated at the same location, whose log restarts at version 0.
561        let listed = delta_io::get_latest_version(&mut reader.table).await;
562        if let Ok(latest) = &listed {
563            let latest = delta_io::to_delta_version(*latest)?;
564            if latest < next_version {
565                return Ok(latest);
566            }
567        }
568        // New commits (or an unreadable listing): load the metadata from scratch, so a
569        // replacement's own metaData action is the one compared with the cursor.
570        let fresh = delta_io::open_table_metadata(
571            &reader.resolved.location,
572            reader.resolved.storage_options.clone(),
573        )
574        .await?;
575        ensure_same_table(&fresh, &table_id)?;
576        listed?;
577        let latest = fresh.version().ok_or_else(|| {
578            ConnectorError::ReadError("opened Delta table has no committed version".into())
579        })?;
580        if latest
581            .checked_add(1)
582            .is_none_or(|after| after < next_version)
583        {
584            return Err(ConnectorError::ConfigurationError(format!(
585                "the Delta log regressed to version {latest} behind the cursor at {next_version}; \
586                 the table was replaced or its history rewritten — reset the source"
587            )));
588        }
589        reader.table = fresh;
590        if latest < next_version {
591            return Ok(latest);
592        }
593        let current = super::schema_resolution::delta_read_schema(&delta_io::get_table_schema(
594            &reader.table,
595        )?);
596        let bound = self
597            .schema_binding
598            .as_ref()
599            .and_then(|binding| binding.external.as_ref());
600        if bound.is_some_and(|bound| bound != current.as_ref()) {
601            return Err(ConnectorError::SchemaMismatch(format!(
602                "the Delta table schema changed by version {latest}; the source binding is \
603                 fixed — migrate the pipeline (DROP/CREATE) to read the new schema"
604            )));
605        }
606        Ok(latest)
607    }
608}
609
610#[cfg(feature = "delta-lake")]
611fn ensure_same_table(table: &DeltaTable, table_id: &str) -> Result<(), ConnectorError> {
612    let snapshot = table.snapshot().map_err(|error| {
613        super::delta_io::classify_delta_metadata_error("read Delta table metadata", &error)
614    })?;
615    if snapshot.metadata().id() != table_id {
616        return Err(ConnectorError::ConfigurationError(
617            "the Delta table was replaced at its location (its table id changed); reset the \
618             source to read the new table"
619                .into(),
620        ));
621    }
622    Ok(())
623}
624
625/// Retrying cannot shrink the unit, so the position overflow is terminal.
626#[cfg(feature = "delta-lake")]
627fn row_position_overflow() -> ConnectorError {
628    ConnectorError::ConfigurationError(
629        "one Delta commit or snapshot file has more than u32::MAX rows, beyond the source's \
630         row-position range"
631            .into(),
632    )
633}
634
635/// Attach replay positions: an empty partition (one channel), the unit's order key, and the
636/// row's ordinal within its unit.
637#[cfg(feature = "delta-lake")]
638fn positioned(
639    batch: RecordBatch,
640    unit: reader::UnitKey,
641    first_row: u32,
642) -> Result<SourceBatch, ConnectorError> {
643    let rows = batch.num_rows();
644    let end = u32::try_from(rows)
645        .ok()
646        .and_then(|rows| first_row.checked_add(rows))
647        .ok_or_else(row_position_overflow)?;
648    let key = unit.order_key();
649    let positions = crate::connector::SourceRowPositions::try_new(
650        BinaryArray::from_iter_values(std::iter::repeat_n(&[][..], rows)),
651        BinaryArray::from_iter_values(std::iter::repeat_n(&key[..], rows)),
652        UInt32Array::from_iter_values(first_row..end),
653    )?;
654    SourceBatch::positioned(batch, positions)
655}
656
657#[async_trait]
658impl SourceConnector for DeltaSource {
659    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
660        Some(self.task_tracker.clone())
661    }
662
663    fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
664        if config.properties().is_empty() {
665            self.config.validate()?;
666        } else {
667            DeltaSourceConfig::from_config(config)?.validate()?;
668        }
669        // Replay re-reads complete commits or snapshot files from the checkpointed cursor in
670        // the kernel's sequential order; nothing is acknowledged upstream.
671        Ok(SourceContract::new(
672            SourceConsistency::Replayable,
673            SourceTopology::Singleton,
674            SourceInputMode::FullChangelog,
675        )
676        .with_row_positions(SourceRowPositionCapability::OrderedDeterministic))
677    }
678
679    async fn resolve_schema(
680        &mut self,
681        config: &ConnectorConfig,
682        explicit: Option<SchemaRef>,
683    ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
684        #[cfg(feature = "delta-lake")]
685        {
686            super::schema_resolution::delta_source(config, explicit).await
687        }
688        #[cfg(not(feature = "delta-lake"))]
689        {
690            let _ = (config, explicit);
691            Err(ConnectorError::FeatureUnsupported(
692                "Delta schema discovery requires the delta-lake feature".into(),
693            ))
694        }
695    }
696
697    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
698        let (config, position, _) = request.into_parts();
699        let recovered = match &position {
700            SourcePosition::Initialized { .. } => {
701                return Err(ConnectorError::ConfigurationError(
702                    "Delta Lake has no sealed topology startup contract".into(),
703                ));
704            }
705            SourcePosition::Initial => None,
706            SourcePosition::Resume { checkpoint, .. } => {
707                Some(DeltaCursor::from_checkpoint(checkpoint)?)
708            }
709        };
710        if !config.properties().is_empty() {
711            self.config = DeltaSourceConfig::from_config(&config)?;
712        }
713        self.config.validate()?;
714        self.state = ConnectorState::Initializing;
715
716        #[cfg(feature = "delta-lake")]
717        {
718            if let Err(error) = self.open(&config, recovered).await {
719                self.state = ConnectorState::Failed;
720                return Err(error);
721            }
722            self.state = ConnectorState::Running;
723            Ok(())
724        }
725
726        #[cfg(not(feature = "delta-lake"))]
727        {
728            let _ = (config, recovered);
729            self.state = ConnectorState::Failed;
730            Err(ConnectorError::ConfigurationError(
731                "Delta Lake source requires the 'delta-lake' feature to be enabled. \
732                 Build with: cargo build --features delta-lake"
733                    .into(),
734            ))
735        }
736    }
737
738    async fn poll_batch(
739        &mut self,
740        max_records: usize,
741    ) -> Result<Option<SourceBatch>, ConnectorError> {
742        if max_records == 0 {
743            return Err(ConnectorError::ConfigurationError(
744                "Delta source max_records must be greater than zero".into(),
745            ));
746        }
747        match self.state {
748            ConnectorState::Running => {}
749            ConnectorState::Failed if cfg!(feature = "delta-lake") => {
750                return Err(ConnectorError::TransactionError(PARTIAL_UNIT_ERROR.into()));
751            }
752            state => {
753                return Err(ConnectorError::InvalidState {
754                    expected: "Running".into(),
755                    actual: state.to_string(),
756                });
757            }
758        }
759
760        #[cfg(feature = "delta-lake")]
761        loop {
762            if let Some(batch) = self.emit_pending(max_records)? {
763                return Ok(Some(batch));
764            }
765            if let Some(output) = self.next_output().await? {
766                match output {
767                    reader::ScanOutput::Batch {
768                        batch,
769                        unit,
770                        permit,
771                        completes,
772                    } => {
773                        self.reader_mut()?.pending = Some(Pending {
774                            batch,
775                            unit,
776                            completes,
777                            _permit: permit,
778                        });
779                    }
780                    reader::ScanOutput::UnitEnd(phase) => {
781                        if self.open_unit.is_some() {
782                            return Err(ConnectorError::Internal(
783                                "Delta reader ended a replay unit without its final rows".into(),
784                            ));
785                        }
786                        self.complete_unit(phase)?;
787                    }
788                }
789                continue;
790            }
791            if !self.start_next_scan().await? {
792                return Ok(None);
793            }
794        }
795
796        #[cfg(not(feature = "delta-lake"))]
797        Err(ConnectorError::FeatureUnsupported(
798            "Delta Lake source requires the 'delta-lake' feature".into(),
799        ))
800    }
801
802    fn schema(&self) -> SchemaRef {
803        self.schema
804            .clone()
805            .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()))
806    }
807
808    fn checkpoint(&self) -> SourceCheckpoint {
809        self.cursor
810            .as_ref()
811            .map_or_else(SourceCheckpoint::new, DeltaCursor::to_checkpoint)
812    }
813
814    fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
815        #[cfg(feature = "delta-lake")]
816        if self.open_unit.is_some() {
817            return Ok(None);
818        }
819        Ok(Some(self.checkpoint()))
820    }
821
822    fn checkpoint_unavailable_policy(&self) -> SourceCheckpointUnavailablePolicy {
823        SourceCheckpointUnavailablePolicy::PollToReplayBoundary
824    }
825
826    async fn close(&mut self) -> Result<(), ConnectorError> {
827        info!("closing Delta Lake source connector");
828
829        #[cfg(feature = "delta-lake")]
830        if let Some(reader) = self.reader.take() {
831            // Closing the budget wakes a reader blocked on it; dropping the receiver ends the
832            // read at its next hand-off. The task tracker observes its termination.
833            reader.context.budget.close();
834            drop(reader);
835            self.open_unit = None;
836        }
837        self.state = ConnectorState::Closed;
838        info!(
839            records_read = self.records_read,
840            "Delta Lake source connector closed"
841        );
842        Ok(())
843    }
844}
845
846impl std::fmt::Debug for DeltaSource {
847    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848        f.debug_struct("DeltaSource")
849            .field("state", &self.state)
850            .field("table_path", &"<configured>")
851            .field("cursor", &self.cursor)
852            .field("records_read", &self.records_read)
853            .finish_non_exhaustive()
854    }
855}
856
857#[cfg(test)]
858mod tests;