-
Notifications
You must be signed in to change notification settings - Fork 66
[Iceberg] Add basic iceberg integration #595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
gabotechs
merged 6 commits into
iceberg-0.10
from
gabrielmusat/add-basic-iceberg-integration
Aug 25, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d5cccf7
Add basic Iceberg integration
gabotechs 2abc5a3
Add CI for iceberg
gabotechs a6cb6af
Fix clippy errors
gabotechs 8f7d284
Ignore failing test in the CI
gabotechs 6f34037
Add lfx to CI and remove ignored test
gabotechs 58eaa41
Fix target_partitions to 4
gabotechs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Iceberg crate guide | ||
|
|
||
| ## Tests | ||
|
|
||
| - Reuse `src/test_utils/harness.rs` and `testdata/iceberg/taxi` as much as | ||
| possible. | ||
| - Prefer integration tests over unit tests when possible. Each isolated feature | ||
| should have its own dedicated file in `tests/`. | ||
| - Never create long tests with a lot of setup, the pattern should be several | ||
| tests with a very small body, and have any necessary helper at the bottom | ||
| of the `mod tests {};` block, below the actual tests. | ||
| - Prefer snapshot testing with `insta` when possible. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # DataFusion Distributed Iceberg | ||
|
|
||
| Read-only Apache Iceberg tables for DataFusion Distributed. | ||
|
|
||
| ```rust | ||
| use datafusion::execution::SessionStateBuilder; | ||
| use datafusion::prelude::SessionContext; | ||
| use datafusion_distributed_iceberg::{IcebergExt, IcebergIntegrationOptions}; | ||
|
|
||
| async fn example() -> datafusion::error::Result<()> { | ||
| let state = SessionStateBuilder::new() | ||
| .with_default_features() | ||
| .with_iceberg_integration(IcebergIntegrationOptions::default()) | ||
| .build(); | ||
| let ctx = SessionContext::new_with_state(state); | ||
|
|
||
| ctx.sql( | ||
| "CREATE EXTERNAL TABLE taxi STORED AS ICEBERG \ | ||
| LOCATION 's3://warehouse/taxi/metadata/v1.metadata.json'", | ||
| ) | ||
| .await? | ||
| .collect() | ||
| .await?; | ||
| Ok(()) | ||
| } | ||
| ``` | ||
|
|
||
| The default storage factory resolves `file://`, S3 (`s3://`, `s3a://`, | ||
| `s3n://`), and GCS (`gs://`, `gcs://`) URIs. Use | ||
| `IcebergIntegrationOptions` to supply custom storage or an Iceberg runtime. | ||
|
|
||
| ```bash | ||
| cargo test -p datafusion-distributed-iceberg | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| use crate::work_unit_feed::FileScanTaskMessage; | ||
| use bytes::{Buf, BufMut}; | ||
| use datafusion::common::Result; | ||
| use datafusion::execution::TaskContext; | ||
| use datafusion::physical_plan::ExecutionPlan; | ||
| use datafusion_proto::physical_plan::PhysicalExtensionCodec; | ||
| use prost::encoding::{DecodeContext, WireType}; | ||
| use prost::{DecodeError, Message}; | ||
| use std::sync::Arc; | ||
|
|
||
| #[derive(Debug)] | ||
| pub struct IcebergCodec; | ||
|
|
||
| // TODO: Implement protobuf codecs for the IcebergDataSource. | ||
| impl PhysicalExtensionCodec for IcebergCodec { | ||
| fn try_decode( | ||
| &self, | ||
| _buf: &[u8], | ||
| _inputs: &[Arc<dyn ExecutionPlan>], | ||
| _ctx: &TaskContext, | ||
| ) -> Result<Arc<dyn ExecutionPlan>> { | ||
| unimplemented!() | ||
| } | ||
|
|
||
| fn try_encode(&self, _node: Arc<dyn ExecutionPlan>, _buf: &mut Vec<u8>) -> Result<()> { | ||
| unimplemented!() | ||
| } | ||
| } | ||
|
|
||
| // TODO: Implement serde for FileScanTaskMessage | ||
| // This message is an individual WorkUnit, but it cannot be serialized yet. During distributed | ||
| // execution, this will be streamed over the wire from coordinator to workers, but for that to | ||
| // happen, it will need to be represented as a prost::Message. | ||
| // | ||
| // WARNING: for the ones who end up implementing this. I have no idea if implementing Message here | ||
| // is really the best option for serialization, it might not be. | ||
| impl Message for FileScanTaskMessage { | ||
| fn encode_raw(&self, _buf: &mut impl BufMut) | ||
| where | ||
| Self: Sized, | ||
| { | ||
| unimplemented!() | ||
| } | ||
|
|
||
| fn merge_field( | ||
| &mut self, | ||
| _tag: u32, | ||
| _wire_type: WireType, | ||
| _buf: &mut impl Buf, | ||
| _ctx: DecodeContext, | ||
| ) -> std::result::Result<(), DecodeError> | ||
| where | ||
| Self: Sized, | ||
| { | ||
| unimplemented!() | ||
| } | ||
|
|
||
| fn encoded_len(&self) -> usize { | ||
| unimplemented!() | ||
| } | ||
|
|
||
| fn clear(&mut self) { | ||
| unimplemented!() | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| use datafusion::error::DataFusionError; | ||
| use iceberg::{Error, ErrorKind}; | ||
|
|
||
| /// Converts a datafusion error into an iceberg error. | ||
| pub fn iceberg_err(error: DataFusionError) -> Error { | ||
| let fallback_message = error.to_string(); | ||
| let DataFusionError::Context(ctx, err) = error else { | ||
| return Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("DataFusion execution failed: {fallback_message}"), | ||
| ); | ||
| }; | ||
|
|
||
| let Some(kind) = parse_iceberg_error_kind(&ctx) else { | ||
| return Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("DataFusion execution failed: {fallback_message}"), | ||
| ); | ||
| }; | ||
| let DataFusionError::Execution(message) = err.as_ref() else { | ||
| return Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("DataFusion execution failed: {fallback_message}"), | ||
| ); | ||
| }; | ||
|
|
||
| Error::new(kind, strip_error_kind(kind, message)) | ||
| } | ||
|
|
||
| /// Converts an Iceberg error into a DataFusion error. | ||
| pub fn df_err(error: Error) -> DataFusionError { | ||
| DataFusionError::Context( | ||
| format!("IcebergError({})", error.kind()), | ||
| Box::new(DataFusionError::Execution(error.to_string())), | ||
| ) | ||
| } | ||
|
|
||
| fn parse_iceberg_error_kind(context: &str) -> Option<ErrorKind> { | ||
| let kind = context.strip_prefix("IcebergError(")?.strip_suffix(')')?; | ||
|
|
||
| match kind { | ||
| "PreconditionFailed" => Some(ErrorKind::PreconditionFailed), | ||
| "Unexpected" => Some(ErrorKind::Unexpected), | ||
| "DataInvalid" => Some(ErrorKind::DataInvalid), | ||
| "NamespaceAlreadyExists" => Some(ErrorKind::NamespaceAlreadyExists), | ||
| "TableAlreadyExists" => Some(ErrorKind::TableAlreadyExists), | ||
| "NamespaceNotFound" => Some(ErrorKind::NamespaceNotFound), | ||
| "TableNotFound" => Some(ErrorKind::TableNotFound), | ||
| "FeatureUnsupported" => Some(ErrorKind::FeatureUnsupported), | ||
| "CatalogCommitConflicts" => Some(ErrorKind::CatalogCommitConflicts), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| fn strip_error_kind(kind: ErrorKind, message: &str) -> String { | ||
| let kind = kind.into_static(); | ||
| if message == kind { | ||
| String::new() | ||
| } else { | ||
| message | ||
| .strip_prefix(&format!("{kind} => ")) | ||
| .unwrap_or(message) | ||
| .to_string() | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use datafusion::error::DataFusionError; | ||
| use iceberg::{Error, ErrorKind}; | ||
|
|
||
| use super::{df_err, iceberg_err}; | ||
|
|
||
| #[test] | ||
| fn roundtrips_iceberg_error_kind_and_message() { | ||
| let error = Error::new(ErrorKind::DataInvalid, "invalid manifest"); | ||
| let roundtripped = iceberg_err(df_err(error)); | ||
|
|
||
| assert_eq!(roundtripped.kind(), ErrorKind::DataInvalid); | ||
| assert_eq!(roundtripped.to_string(), "DataInvalid => invalid manifest"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn encodes_iceberg_errors_with_native_datafusion_variants() { | ||
| let error = df_err(Error::new(ErrorKind::DataInvalid, "invalid manifest")); | ||
|
|
||
| assert!(matches!( | ||
| error, | ||
| DataFusionError::Context(context, inner) | ||
| if context == "IcebergError(DataInvalid)" | ||
| && matches!(inner.as_ref(), DataFusionError::Execution(message) if message == "DataInvalid => invalid manifest") | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn maps_non_iceberg_datafusion_errors_to_unexpected() { | ||
| let error = iceberg_err(DataFusionError::Execution("worker failed".to_string())); | ||
|
|
||
| assert_eq!(error.kind(), ErrorKind::Unexpected); | ||
| assert_eq!( | ||
| error.to_string(), | ||
| "Unexpected => DataFusion execution failed: Execution error: worker failed" | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we have structured error handling here instead of the string? Can be follow up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The challenge with this is that this approach would not survive a serialization roundtrip.
When propagating DataFusionError messages across the network, we use https://github.com/datafusion-contrib/datafusion-distributed/blob/9dd018a392185c6853f4d32a43d839219b31730d/src/protocol/grpc/errors, and any error that relies on type downcasting is lost in the way.