laminar_connectors/lakehouse/iceberg_lookup/
mod.rs1use 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#[derive(Debug, Clone)]
39pub struct IcebergLookupSourceConfig {
40 pub catalog: IcebergCatalogConfig,
42 pub storage: IcebergStorageConfig,
44 pub primary_key_columns: Vec<String>,
46}
47
48pub 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 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 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 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 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;