Skip to main content

laminar_connectors/lakehouse/iceberg_lookup/
mod.rs

1//! Iceberg on-demand lookup source for cache-miss fallback.
2//!
3//! Implements `LookupSource` via the native Iceberg scan with a batched
4//! `pk IN (...)` filter pushed down (`with_filter`), so all missed keys of a
5//! probe fold into one manifest-pruned scan. [`KeyAligner`] handles key decode
6//! and result realignment.
7
8use std::sync::Arc;
9
10use arrow_array::{Array, ArrayRef, RecordBatch};
11use arrow_row::SortField;
12use arrow_schema::SchemaRef;
13use futures_util::StreamExt;
14use iceberg::expr::{Predicate as IcebergPredicate, Reference};
15use iceberg::spec::Datum;
16use iceberg::Catalog;
17
18use laminar_core::lookup::predicate::Predicate;
19use laminar_core::lookup::source::{
20    projection_names, ColumnId, LookupError, LookupSource, LookupSourceCapabilities,
21};
22use laminar_core::lookup::KeyAligner;
23
24use crate::lakehouse::iceberg_config::{
25    validate_io_timeout, IcebergCatalogConfig, IcebergStorageConfig,
26};
27use crate::lakehouse::iceberg_scan::{
28    connector_scan_error, plan_files, preflight_snapshot, ManifestReadLimits,
29    DEFAULT_MAX_PLANNED_FILES,
30};
31
32const MAX_LOOKUP_KEYS: usize = 4_096;
33const MAX_LOOKUP_KEY_BYTES: usize = 4 * 1024 * 1024;
34const MAX_LOOKUP_RESULT_BYTES: usize = 64 * 1024 * 1024;
35const LOOKUP_SCAN_CONCURRENCY: usize = 4;
36
37/// Configuration for [`IcebergLookupSource`].
38#[derive(Debug, Clone)]
39pub struct IcebergLookupSourceConfig {
40    /// Shared catalog connection settings (also carries namespace + table).
41    pub catalog: IcebergCatalogConfig,
42    /// Table data-storage settings, separate from catalog authentication.
43    pub storage: IcebergStorageConfig,
44    /// Primary key column names.
45    pub primary_key_columns: Vec<String>,
46}
47
48/// Iceberg lookup source for on-demand/partial cache mode.
49pub struct IcebergLookupSource {
50    catalog: Arc<dyn Catalog>,
51    namespace: String,
52    table_name: String,
53    catalog_request_timeout: std::time::Duration,
54    storage_request_timeout: std::time::Duration,
55    schema: SchemaRef,
56    aligner: KeyAligner,
57}
58
59impl IcebergLookupSource {
60    /// Opens the catalog, loads the table, and derives the Arrow schema.
61    ///
62    /// # Errors
63    ///
64    /// Returns `LookupError` if the catalog/table cannot be opened or a primary
65    /// key column is missing from the table schema.
66    pub async fn open(config: IcebergLookupSourceConfig) -> Result<Self, LookupError> {
67        validate_lookup_config(&config)?;
68        let catalog = crate::lakehouse::iceberg_io::build_catalog(&config.catalog, &config.storage)
69            .await
70            .map_err(|e| LookupError::Connection(format!("iceberg catalog: {e}")))?;
71        let table = crate::lakehouse::iceberg_io::load_table_with_timeout(
72            catalog.as_ref(),
73            &config.catalog.namespace,
74            &config.catalog.table_name,
75            config.catalog.request_timeout,
76        )
77        .await
78        .map_err(|e| LookupError::Connection(format!("load iceberg table: {e}")))?;
79
80        let iceberg_schema = table.current_schema_ref();
81        let schema: SchemaRef = Arc::new(
82            iceberg::arrow::schema_to_arrow_schema(&iceberg_schema)
83                .map_err(|e| LookupError::Internal(format!("iceberg schema to arrow: {e}")))?,
84        );
85
86        let pk_sort_fields = config
87            .primary_key_columns
88            .iter()
89            .map(|name| {
90                let idx = schema
91                    .index_of(name)
92                    .map_err(|_| LookupError::Internal(format!("pk column not found: {name}")))?;
93                Ok(SortField::new(schema.field(idx).data_type().clone()))
94            })
95            .collect::<Result<Vec<_>, LookupError>>()?;
96        let aligner = KeyAligner::new(pk_sort_fields, config.primary_key_columns)?;
97
98        Ok(Self {
99            catalog,
100            namespace: config.catalog.namespace,
101            table_name: config.catalog.table_name,
102            catalog_request_timeout: config.catalog.request_timeout,
103            storage_request_timeout: config.storage.request_timeout,
104            schema,
105            aligner,
106        })
107    }
108
109    /// Convert one PK cell into an Iceberg [`Datum`], or `None` when NULL.
110    fn cell_to_datum(
111        col_name: &str,
112        array: &dyn Array,
113        row: usize,
114    ) -> Result<Option<Datum>, LookupError> {
115        use arrow_array::{
116            BooleanArray, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array,
117            Int8Array, LargeStringArray, StringArray, StringViewArray,
118        };
119        use arrow_schema::DataType;
120
121        if array.is_null(row) {
122            return Ok(None);
123        }
124
125        macro_rules! downcast {
126            ($ty:ty) => {
127                array.as_any().downcast_ref::<$ty>().ok_or_else(|| {
128                    LookupError::Internal(format!("pk column '{col_name}' downcast failed"))
129                })?
130            };
131        }
132
133        let datum = match array.data_type() {
134            DataType::Int8 => Datum::int(i32::from(downcast!(Int8Array).value(row))),
135            DataType::Int16 => Datum::int(i32::from(downcast!(Int16Array).value(row))),
136            DataType::Int32 => Datum::int(downcast!(Int32Array).value(row)),
137            DataType::Int64 => Datum::long(downcast!(Int64Array).value(row)),
138            DataType::Float32 => Datum::float(downcast!(Float32Array).value(row)),
139            DataType::Float64 => Datum::double(downcast!(Float64Array).value(row)),
140            DataType::Boolean => Datum::bool(downcast!(BooleanArray).value(row)),
141            DataType::Utf8 => Datum::string(downcast!(StringArray).value(row)),
142            DataType::LargeUtf8 => Datum::string(downcast!(LargeStringArray).value(row)),
143            DataType::Utf8View => Datum::string(downcast!(StringViewArray).value(row)),
144            dt => {
145                return Err(LookupError::Internal(format!(
146                    "unsupported PK data type for iceberg lookup: {dt} (column \"{col_name}\")"
147                )));
148            }
149        };
150        Ok(Some(datum))
151    }
152
153    /// Build a single Iceberg predicate over the decoded key columns: a
154    /// single-column PK folds into `pk IN (...)`; a composite PK becomes an OR
155    /// of per-key AND-groups.
156    fn build_key_predicate(
157        pk_cols: &[String],
158        pk_arrays: &[ArrayRef],
159        n_keys: usize,
160    ) -> Result<IcebergPredicate, LookupError> {
161        if pk_cols.len() == 1 {
162            let col = &pk_cols[0];
163            let array = pk_arrays[0].as_ref();
164            let mut datums = Vec::with_capacity(n_keys);
165            let mut has_null = false;
166            for row in 0..n_keys {
167                match Self::cell_to_datum(col, array, row)? {
168                    Some(d) => datums.push(d),
169                    None => has_null = true,
170                }
171            }
172            let mut pred: Option<IcebergPredicate> =
173                (!datums.is_empty()).then(|| Reference::new(col.clone()).is_in(datums));
174            if has_null {
175                let null_pred = Reference::new(col.clone()).is_null();
176                pred = Some(match pred {
177                    Some(p) => p.or(null_pred),
178                    None => null_pred,
179                });
180            }
181            return pred.ok_or_else(|| LookupError::Internal("no keys to look up".into()));
182        }
183
184        let mut groups: Vec<IcebergPredicate> = Vec::with_capacity(n_keys);
185        for row in 0..n_keys {
186            let mut conj: Option<IcebergPredicate> = None;
187            for (ci, col) in pk_cols.iter().enumerate() {
188                let term = match Self::cell_to_datum(col, pk_arrays[ci].as_ref(), row)? {
189                    Some(d) => Reference::new(col.clone()).equal_to(d),
190                    None => Reference::new(col.clone()).is_null(),
191                };
192                conj = Some(match conj {
193                    Some(c) => c.and(term),
194                    None => term,
195                });
196            }
197            if let Some(c) = conj {
198                groups.push(c);
199            }
200        }
201        let mut it = groups.into_iter();
202        it.next()
203            .map(|first| it.fold(first, IcebergPredicate::or))
204            .ok_or_else(|| LookupError::Internal("no keys to look up".into()))
205    }
206}
207
208fn validate_lookup_config(config: &IcebergLookupSourceConfig) -> Result<(), LookupError> {
209    if config.primary_key_columns.is_empty() || config.primary_key_columns.len() > 128 {
210        return Err(LookupError::Internal(
211            "Iceberg lookup requires between 1 and 128 primary-key columns".into(),
212        ));
213    }
214    let mut names = std::collections::HashSet::with_capacity(config.primary_key_columns.len());
215    for name in &config.primary_key_columns {
216        if name.is_empty() || !names.insert(name) {
217            return Err(LookupError::Internal(
218                "Iceberg lookup primary-key columns must be nonempty and distinct".into(),
219            ));
220        }
221    }
222    for (name, timeout) in [
223        ("catalog.connect_timeout", config.catalog.connect_timeout),
224        ("catalog.request_timeout", config.catalog.request_timeout),
225        ("storage.request_timeout", config.storage.request_timeout),
226        ("storage.connect_timeout", config.storage.connect_timeout),
227    ] {
228        validate_io_timeout(name, timeout)
229            .map_err(|error| LookupError::Internal(error.to_string()))?;
230    }
231    Ok(())
232}
233
234fn validate_lookup_keys(keys: &[&[u8]]) -> Result<(), LookupError> {
235    if keys.len() > MAX_LOOKUP_KEYS {
236        return Err(LookupError::Query(format!(
237            "Iceberg lookup received {} keys, exceeding the fixed {MAX_LOOKUP_KEYS}-key batch limit",
238            keys.len()
239        )));
240    }
241    let bytes = keys.iter().try_fold(0_usize, |total, key| {
242        total
243            .checked_add(key.len())
244            .ok_or_else(|| LookupError::Query("Iceberg lookup key byte count overflow".into()))
245    })?;
246    if bytes > MAX_LOOKUP_KEY_BYTES {
247        return Err(LookupError::Query(format!(
248            "Iceberg lookup received {bytes} key bytes, exceeding the fixed {MAX_LOOKUP_KEY_BYTES}-byte batch limit"
249        )));
250    }
251    Ok(())
252}
253
254async fn read_lookup_batches(
255    mut stream: iceberg::scan::ArrowRecordBatchStream,
256    max_rows: usize,
257    deadline: tokio::time::Instant,
258    timeout: std::time::Duration,
259) -> Result<Vec<RecordBatch>, LookupError> {
260    let mut batches = Vec::new();
261    let mut rows = 0_usize;
262    let mut bytes = 0_usize;
263    loop {
264        let next = tokio::time::timeout_at(deadline, stream.next())
265            .await
266            .map_err(|_| LookupError::Timeout(timeout))?;
267        let Some(result) = next else {
268            return Ok(batches);
269        };
270        let batch = result.map_err(|error| {
271            LookupError::Query(
272                connector_scan_error("Iceberg lookup data read failed", &error).to_string(),
273            )
274        })?;
275        rows = rows
276            .checked_add(batch.num_rows())
277            .ok_or_else(|| LookupError::Query("Iceberg lookup result row count overflow".into()))?;
278        if rows > max_rows {
279            return Err(LookupError::Query(format!(
280                "Iceberg lookup returned more than {max_rows} distinct-key rows"
281            )));
282        }
283        bytes = batch.columns().iter().try_fold(bytes, |total, column| {
284            total
285                .checked_add(column.get_array_memory_size())
286                .ok_or_else(|| {
287                    LookupError::Query("Iceberg lookup result byte count overflow".into())
288                })
289        })?;
290        if bytes > MAX_LOOKUP_RESULT_BYTES {
291            return Err(LookupError::Query(format!(
292                "Iceberg lookup retained {bytes} result bytes, exceeding the fixed {MAX_LOOKUP_RESULT_BYTES}-byte limit"
293            )));
294        }
295        if batch.num_rows() > 0 {
296            batches.push(batch);
297        }
298    }
299}
300
301fn project_aligned_rows(
302    aligned: Vec<Option<RecordBatch>>,
303    names: &[String],
304) -> Result<Vec<Option<RecordBatch>>, LookupError> {
305    aligned
306        .into_iter()
307        .map(|batch| {
308            batch
309                .map(|batch| {
310                    let indices = names
311                        .iter()
312                        .map(|name| {
313                            batch.schema().index_of(name).map_err(|_| {
314                                LookupError::Internal(format!(
315                                    "Iceberg lookup result omitted projected column '{name}'"
316                                ))
317                            })
318                        })
319                        .collect::<Result<Vec<_>, _>>()?;
320                    batch.project(&indices).map_err(|error| {
321                        LookupError::Internal(format!(
322                            "project aligned Iceberg lookup row: {error}"
323                        ))
324                    })
325                })
326                .transpose()
327        })
328        .collect()
329}
330
331impl LookupSource for IcebergLookupSource {
332    async fn query(
333        &self,
334        keys: &[&[u8]],
335        _predicates: &[Predicate],
336        projection: &[ColumnId],
337    ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
338        if keys.is_empty() {
339            return Ok(Vec::new());
340        }
341        validate_lookup_keys(keys)?;
342
343        let pk_arrays = self.aligner.decode_keys(keys)?;
344        let predicate =
345            Self::build_key_predicate(self.aligner.pk_columns(), &pk_arrays, keys.len())?;
346
347        // Reload the table per query so lookups see the latest snapshot
348        // (eventually-consistent freshness; the cache layer adds TTL on top).
349        let table = crate::lakehouse::iceberg_io::load_table_with_timeout(
350            self.catalog.as_ref(),
351            &self.namespace,
352            &self.table_name,
353            self.catalog_request_timeout,
354        )
355        .await
356        .map_err(|e| LookupError::Query(format!("load iceberg table: {e}")))?;
357
358        let requested_names = projection_names(&self.schema, projection)?;
359        let mut scan_names = requested_names.clone();
360        if !projection.is_empty() {
361            for key_name in self.aligner.pk_columns() {
362                if !scan_names.contains(key_name) {
363                    scan_names.push(key_name.clone());
364                }
365            }
366        }
367        let project_after_alignment = scan_names != requested_names;
368        let Some(snapshot) = table.metadata().current_snapshot() else {
369            return Ok(vec![None; keys.len()]);
370        };
371        let mut builder = table
372            .scan()
373            .snapshot_id(snapshot.snapshot_id())
374            .with_filter(predicate)
375            .with_concurrency_limit(LOOKUP_SCAN_CONCURRENCY);
376        builder = builder.select(&scan_names);
377        let scan = builder.build().map_err(|error| {
378            LookupError::Query(
379                connector_scan_error("build Iceberg lookup scan", &error).to_string(),
380            )
381        })?;
382        let deadline = crate::lakehouse::iceberg_io::checked_deadline(
383            self.storage_request_timeout,
384            "storage.request_timeout",
385        )
386        .map_err(|error| LookupError::Query(error.to_string()))?;
387        preflight_snapshot(&table, snapshot, ManifestReadLimits::fixed(), deadline)
388            .await
389            .map_err(|error| LookupError::Query(error.to_string()))?;
390        let tasks = plan_files(&scan, DEFAULT_MAX_PLANNED_FILES, deadline)
391            .await
392            .map_err(|error| LookupError::Query(error.to_string()))?;
393        let reader = table
394            .reader_builder()
395            .with_batch_size(8_192)
396            .with_data_file_concurrency_limit(LOOKUP_SCAN_CONCURRENCY)
397            .build()
398            .read(tasks)
399            .map_err(|error| {
400                LookupError::Query(
401                    connector_scan_error("create Iceberg lookup reader", &error).to_string(),
402                )
403            })?;
404        let unique_keys = keys
405            .iter()
406            .copied()
407            .collect::<std::collections::HashSet<_>>()
408            .len();
409        let batches = read_lookup_batches(
410            reader.stream(),
411            unique_keys,
412            deadline,
413            self.storage_request_timeout,
414        )
415        .await?;
416        let aligned = self.aligner.align(keys, &batches)?;
417        if project_after_alignment {
418            project_aligned_rows(aligned, &requested_names)
419        } else {
420            Ok(aligned)
421        }
422    }
423
424    fn capabilities(&self) -> LookupSourceCapabilities {
425        LookupSourceCapabilities {
426            supports_batch_lookup: true,
427            supports_projection_pushdown: true,
428            max_batch_size: MAX_LOOKUP_KEYS,
429            ..LookupSourceCapabilities::none()
430        }
431    }
432
433    fn source_name(&self) -> &'static str {
434        "iceberg"
435    }
436
437    fn schema(&self) -> SchemaRef {
438        Arc::clone(&self.schema)
439    }
440
441    async fn health_check(&self) -> Result<(), LookupError> {
442        crate::lakehouse::iceberg_io::load_table_with_timeout(
443            self.catalog.as_ref(),
444            &self.namespace,
445            &self.table_name,
446            self.catalog_request_timeout,
447        )
448        .await
449        .map(|_| ())
450        .map_err(|e| LookupError::Connection(format!("health check: {e}")))
451    }
452}
453
454#[cfg(test)]
455mod tests;