laminar_connectors/lakehouse/
delta_reference.rs1use std::collections::VecDeque;
4
5use arrow_array::RecordBatch;
6use arrow_schema::SchemaRef;
7use deltalake::DeltaTable;
8use tracing::info;
9
10use crate::config::ConnectorConfig;
11use crate::error::ConnectorError;
12use crate::lakehouse::delta_source_config::DeltaSourceConfig;
13use crate::reference::ReferenceTableSource;
14
15use super::snapshot_schema::{conform_snapshot_batch, validate_snapshot_schema};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum Phase {
19 Ready,
20 Draining,
21 Done,
22 Closed,
23}
24
25pub struct DeltaReferenceTableSource {
27 config: DeltaSourceConfig,
28 declared_schema: SchemaRef,
29 phase: Phase,
30 table: Option<DeltaTable>,
31 pending_batches: VecDeque<RecordBatch>,
32}
33
34impl DeltaReferenceTableSource {
35 #[must_use]
37 pub fn from_source_config(config: DeltaSourceConfig, declared_schema: SchemaRef) -> Self {
38 Self {
39 config,
40 declared_schema,
41 phase: Phase::Ready,
42 table: None,
43 pending_batches: VecDeque::new(),
44 }
45 }
46
47 pub fn from_connector_config(
53 config: &ConnectorConfig,
54 declared_schema: SchemaRef,
55 ) -> Result<Self, ConnectorError> {
56 Ok(Self::from_source_config(
57 DeltaSourceConfig::from_config(config)?,
58 declared_schema,
59 ))
60 }
61
62 async fn open_table(&mut self) -> Result<(), ConnectorError> {
63 use crate::lakehouse::delta_io;
64
65 let (resolved_path, resolved_options) = delta_io::resolve_catalog_options(
66 &self.config.catalog_type,
67 self.config.catalog_database.as_deref(),
68 self.config.catalog_name.as_deref(),
69 self.config.catalog_schema.as_deref(),
70 &self.config.table_path,
71 &self.config.storage_options,
72 )
73 .await?;
74 self.table =
75 Some(delta_io::open_or_create_table(&resolved_path, resolved_options, None).await?);
76 Ok(())
77 }
78
79 async fn load_snapshot(&mut self) -> Result<(), ConnectorError> {
80 use crate::lakehouse::delta_io;
81
82 let version = delta_io::get_latest_version(
83 self.table
84 .as_mut()
85 .ok_or_else(|| ConnectorError::Internal("Delta table is not open".into()))?,
86 )
87 .await?;
88 let batches = if self.config.partition_filter.is_some() {
89 self.load_filtered_snapshot(version).await?
90 } else {
91 delta_io::read_batches_at_version(
92 self.table
93 .as_mut()
94 .ok_or_else(|| ConnectorError::Internal("Delta table is not open".into()))?,
95 version,
96 usize::MAX,
97 )
98 .await?
99 .0
100 };
101
102 if batches.is_empty() {
103 let table_schema = delta_io::get_table_schema(
104 self.table
105 .as_ref()
106 .ok_or_else(|| ConnectorError::Internal("Delta table is not open".into()))?,
107 )
108 .map_err(|error| {
109 ConnectorError::ReadError(format!("read Delta snapshot schema: {error}"))
110 })?;
111 validate_snapshot_schema(table_schema.as_ref(), self.declared_schema.as_ref())?;
112 }
113
114 let batches = batches
115 .into_iter()
116 .map(|batch| conform_snapshot_batch(&batch, &self.declared_schema))
117 .collect::<Result<Vec<_>, _>>()?;
118 let rows = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
119 info!(version, rows, "Delta reference snapshot loaded");
120 self.pending_batches = batches.into();
121 Ok(())
122 }
123
124 async fn load_filtered_snapshot(
125 &mut self,
126 version: i64,
127 ) -> Result<Vec<RecordBatch>, ConnectorError> {
128 use tokio_stream::StreamExt;
129
130 let table = self
131 .table
132 .as_mut()
133 .ok_or_else(|| ConnectorError::Internal("Delta table is not open".into()))?;
134 table
135 .load_version(version)
136 .await
137 .map_err(|error| ConnectorError::ReadError(format!("load Delta version: {error}")))?;
138 let provider = table
139 .table_provider()
140 .build()
141 .await
142 .map_err(|error| ConnectorError::ReadError(format!("build Delta scan: {error}")))?;
143
144 let context = datafusion::prelude::SessionContext::new();
145 context
146 .register_table("delta_reference_scan", std::sync::Arc::new(provider))
147 .map_err(|error| ConnectorError::ReadError(format!("register Delta scan: {error}")))?;
148 let filter = self.config.partition_filter.as_deref().ok_or_else(|| {
149 ConnectorError::Internal("filtered Delta snapshot has no filter".into())
150 })?;
151 let dataframe = context
152 .sql(&format!(
153 "SELECT * FROM delta_reference_scan WHERE {filter}"
154 ))
155 .await
156 .map_err(|error| {
157 ConnectorError::ReadError(format!("plan filtered Delta scan: {error}"))
158 })?;
159 let mut stream = dataframe.execute_stream().await.map_err(|error| {
160 ConnectorError::ReadError(format!("execute filtered Delta scan: {error}"))
161 })?;
162 let mut batches = Vec::new();
163 while let Some(batch) = stream.next().await {
164 let batch = batch.map_err(|error| {
165 ConnectorError::ReadError(format!("read filtered Delta batch: {error}"))
166 })?;
167 if batch.num_rows() != 0 {
168 batches.push(batch);
169 }
170 }
171 Ok(batches)
172 }
173}
174
175#[async_trait::async_trait]
176impl ReferenceTableSource for DeltaReferenceTableSource {
177 async fn poll_snapshot(&mut self) -> Result<Option<RecordBatch>, ConnectorError> {
178 match self.phase {
179 Phase::Closed => {
180 return Err(ConnectorError::InvalidState {
181 expected: "open reference snapshot source".into(),
182 actual: "closed".into(),
183 });
184 }
185 Phase::Done => return Ok(None),
186 Phase::Ready => {
187 self.open_table().await?;
188 self.load_snapshot().await?;
189 self.phase = Phase::Draining;
190 }
191 Phase::Draining => {}
192 }
193
194 if let Some(batch) = self.pending_batches.pop_front() {
195 return Ok(Some(batch));
196 }
197 self.phase = Phase::Done;
198 Ok(None)
199 }
200
201 async fn close(&mut self) -> Result<(), ConnectorError> {
202 self.phase = Phase::Closed;
203 self.pending_batches.clear();
204 self.table = None;
205 Ok(())
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use std::sync::Arc;
212
213 use arrow_schema::{DataType, Field, Schema};
214
215 use super::*;
216
217 fn declared_schema() -> SchemaRef {
218 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]))
219 }
220
221 #[test]
222 fn construction_carries_declared_schema() {
223 let source = DeltaReferenceTableSource::from_source_config(
224 DeltaSourceConfig::new("/tmp/test_delta"),
225 declared_schema(),
226 );
227 assert_eq!(source.declared_schema.field(0).name(), "id");
228 assert!(!source.declared_schema.field(0).is_nullable());
229 }
230
231 #[test]
232 fn missing_table_path_is_rejected() {
233 let config = ConnectorConfig::new("delta-lake");
234 assert!(
235 DeltaReferenceTableSource::from_connector_config(&config, declared_schema()).is_err()
236 );
237 }
238
239 #[tokio::test]
240 async fn close_is_idempotent_and_prevents_reads() {
241 let mut source = DeltaReferenceTableSource::from_source_config(
242 DeltaSourceConfig::new("/tmp/test_delta"),
243 declared_schema(),
244 );
245 source.close().await.unwrap();
246 source.close().await.unwrap();
247 assert!(source.poll_snapshot().await.is_err());
248 }
249
250 mod integration {
251 use std::collections::HashMap;
252
253 use arrow_array::{Int64Array, StringArray};
254 use deltalake::protocol::SaveMode;
255 use tempfile::TempDir;
256
257 use super::*;
258
259 fn physical_schema() -> SchemaRef {
260 Arc::new(Schema::new(vec![
261 Field::new("id", DataType::Int64, true),
262 Field::new("name", DataType::Utf8, true),
263 ]))
264 }
265
266 fn declared_schema() -> SchemaRef {
267 Arc::new(Schema::new(vec![
268 Field::new("id", DataType::Int64, false),
269 Field::new("name", DataType::Utf8, true),
270 ]))
271 }
272
273 async fn write_table(path: &str) {
274 use crate::lakehouse::delta_io;
275
276 let schema = physical_schema();
277 let batch = RecordBatch::try_new(
278 schema.clone(),
279 vec![
280 Arc::new(Int64Array::from(vec![1, 2])),
281 Arc::new(StringArray::from(vec!["one", "two"])),
282 ],
283 )
284 .unwrap();
285 let table = delta_io::open_or_create_table(path, HashMap::new(), Some(&schema))
286 .await
287 .unwrap();
288 delta_io::write_batches(
289 table,
290 vec![batch],
291 SaveMode::Append,
292 None,
293 false,
294 None,
295 None,
296 )
297 .await
298 .unwrap();
299 }
300
301 #[tokio::test]
302 async fn snapshot_uses_declared_schema_and_exhausts() {
303 let directory = TempDir::new().unwrap();
304 let path = directory.path().to_str().unwrap();
305 write_table(path).await;
306 let mut source = DeltaReferenceTableSource::from_source_config(
307 DeltaSourceConfig::new(path),
308 declared_schema(),
309 );
310
311 let batch = source.poll_snapshot().await.unwrap().unwrap();
312 assert_eq!(batch.schema(), declared_schema());
313 assert_eq!(batch.num_rows(), 2);
314 assert!(source.poll_snapshot().await.unwrap().is_none());
315 assert!(source.poll_snapshot().await.unwrap().is_none());
316 }
317
318 #[tokio::test]
319 async fn incompatible_declared_schema_fails_closed() {
320 let directory = TempDir::new().unwrap();
321 let path = directory.path().to_str().unwrap();
322 write_table(path).await;
323 let incompatible = Arc::new(Schema::new(vec![
324 Field::new("id", DataType::Utf8, false),
325 Field::new("name", DataType::Utf8, true),
326 ]));
327 let mut source = DeltaReferenceTableSource::from_source_config(
328 DeltaSourceConfig::new(path),
329 incompatible,
330 );
331
332 assert!(source.poll_snapshot().await.is_err());
333 }
334 }
335}