This is an automated email from the ASF dual-hosted git repository.
mbutrovich pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new bc53ad5c56 feat: Iceberg table format V3: native table decryption,
fall back for other V3 features (#4991)
bc53ad5c56 is described below
commit bc53ad5c56eff6e4e3c2d702a6869b7cf28352ae
Author: Matt Butrovich <[email protected]>
AuthorDate: Thu Jul 23 18:25:23 2026 -0400
feat: Iceberg table format V3: native table decryption, fall back for other
V3 features (#4991)
---
.github/actions/java-test/action.yaml | 14 +-
.github/workflows/pr_build_linux.yml | 1 +
.github/workflows/pr_build_macos.yml | 1 +
dev/diffs/iceberg/1.11.0.diff | 25 ++
docs/source/contributor-guide/roadmap.md | 6 +-
docs/source/user-guide/latest/iceberg.md | 22 +-
native/Cargo.lock | 76 +++---
native/Cargo.toml | 8 +-
.../core/src/execution/operators/iceberg_scan.rs | 57 +++++
native/core/src/execution/planner.rs | 8 +-
native/proto/src/proto/operator.proto | 14 ++
spark/pom.xml | 7 +
.../apache/comet/iceberg/IcebergReflection.scala | 63 +++++
.../org/apache/comet/rules/CometScanRule.scala | 86 ++++++-
.../serde/operator/CometIcebergNativeScan.scala | 29 +++
.../org/apache/comet/CometIcebergTestBase.scala | 2 +-
.../apache/comet/CometIcebergEncryptionSuite.scala | 261 +++++++++++++++++++++
.../org/apache/comet/iceberg/CometTestKMS.scala | 63 +++++
18 files changed, 682 insertions(+), 61 deletions(-)
diff --git a/.github/actions/java-test/action.yaml
b/.github/actions/java-test/action.yaml
index 7ec586bd10..a5ca166591 100644
--- a/.github/actions/java-test/action.yaml
+++ b/.github/actions/java-test/action.yaml
@@ -50,6 +50,18 @@ runs:
cd native
cargo build --release
+ # Evaluate hashFiles now, on the clean checkout, and reuse the result in
the
+ # cache key below. The cache step's inputs are re-evaluated when its post
+ # (cache-save) phase runs, and by then the test run has left trees under
the
+ # workspace (e.g. the Hive-catalog warehouse from
CometIcebergEncryptionSuite)
+ # that make a fresh '**/pom.xml' walk throw "Fail to hash files under
+ # directory". A precomputed string keeps the save phase from re-walking
them.
+ - name: Compute Maven cache key
+ id: maven-cache-key
+ if: ${{ runner.os != 'macOS' }}
+ shell: bash
+ run: echo "hash=${{ hashFiles('**/pom.xml') }}" >> "$GITHUB_OUTPUT"
+
- name: Cache Maven dependencies
# TODO: remove next line after working again
# temporarily work around
https://github.com/actions/runner-images/issues/13341
@@ -60,7 +72,7 @@ runs:
path: |
~/.m2/repository
/root/.m2/repository
- key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}
+ key: ${{ runner.os }}-java-maven-${{
steps.maven-cache-key.outputs.hash }}
restore-keys: |
${{ runner.os }}-java-maven-
diff --git a/.github/workflows/pr_build_linux.yml
b/.github/workflows/pr_build_linux.yml
index 6aaac53204..019ae27722 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -312,6 +312,7 @@ jobs:
org.apache.spark.sql.comet.ParquetEncryptionITCase
org.apache.comet.exec.CometNativeReaderSuite
org.apache.comet.CometIcebergNativeSuite
+ org.apache.comet.CometIcebergEncryptionSuite
org.apache.comet.CometIcebergRewriteActionSuite
org.apache.comet.iceberg.IcebergReflectionSuite
org.apache.comet.csv.CometCsvNativeReadSuite
diff --git a/.github/workflows/pr_build_macos.yml
b/.github/workflows/pr_build_macos.yml
index 36ed5f9405..0c02fdf8ce 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -128,6 +128,7 @@ jobs:
org.apache.spark.sql.comet.ParquetEncryptionITCase
org.apache.comet.exec.CometNativeReaderSuite
org.apache.comet.CometIcebergNativeSuite
+ org.apache.comet.CometIcebergEncryptionSuite
org.apache.comet.CometIcebergRewriteActionSuite
org.apache.comet.iceberg.IcebergReflectionSuite
org.apache.comet.csv.CometCsvNativeReadSuite
diff --git a/dev/diffs/iceberg/1.11.0.diff b/dev/diffs/iceberg/1.11.0.diff
index a404ded20a..ca56ab9b93 100644
--- a/dev/diffs/iceberg/1.11.0.diff
+++ b/dev/diffs/iceberg/1.11.0.diff
@@ -251,6 +251,31 @@ index 507d7b313b..3b73dcc014 100644
.config("spark.ui.enabled", "false")
.config(DISABLE_UI)
.enableHiveSupport()
+diff --git
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java
+index 8ce60f6275..75112b4354 100644
+---
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java
++++
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java
+@@ -48,7 +48,6 @@ import org.apache.iceberg.types.Types.LongType;
+ import org.apache.iceberg.types.Types.MapType;
+ import org.apache.iceberg.types.Types.StructType;
+ import org.apache.iceberg.util.DateTimeUtil;
+-import org.assertj.core.api.Condition;
+ import org.junit.jupiter.api.Test;
+ import org.junit.jupiter.api.io.TempDir;
+ import org.junit.jupiter.params.ParameterizedTest;
+@@ -385,12 +384,6 @@ public abstract class AvroDataTestBase {
+ .build());
+
+ assertThatThrownBy(() -> writeAndValidate(writeSchema, expectedSchema))
+- .has(
+- new Condition<>(
+- t ->
+- IllegalArgumentException.class.isInstance(t)
+- ||
IllegalArgumentException.class.isInstance(t.getCause()),
+- "Expecting a throwable or cause that is an instance of
IllegalArgumentException"))
+ .hasMessageContaining("Missing required field: missing_str");
+ }
+
diff --git
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java
index b61ecfa2f4..d696e85139 100644
---
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java
diff --git a/docs/source/contributor-guide/roadmap.md
b/docs/source/contributor-guide/roadmap.md
index b8ae8a310f..59aba8e7c4 100644
--- a/docs/source/contributor-guide/roadmap.md
+++ b/docs/source/contributor-guide/roadmap.md
@@ -52,14 +52,14 @@ significant family of Spark expressions in one effort.
Native Parquet scans (`CometNativeScanExec`) support Dynamic Partition Pruning
(DPP) both with and without
Adaptive Query Execution. Non-AQE DPP landed in [#4011] and AQE DPP with
broadcast reuse landed in [#4112].
-Iceberg native scans currently support non-AQE DPP only ([#3349], [#3511]);
extending broadcast reuse to AQE
-DPP for Iceberg is tracked at [#3510].
+Iceberg native scans support non-AQE DPP ([#3349], [#3511]) and, on Spark
3.5+, AQE DPP with broadcast reuse
+([#4215]); on Spark 3.4 Iceberg AQE DPP falls back to Spark without reuse.
[#3349]: https://github.com/apache/datafusion-comet/pull/3349
-[#3510]: https://github.com/apache/datafusion-comet/issues/3510
[#3511]: https://github.com/apache/datafusion-comet/pull/3511
[#4011]: https://github.com/apache/datafusion-comet/pull/4011
[#4112]: https://github.com/apache/datafusion-comet/pull/4112
+[#4215]: https://github.com/apache/datafusion-comet/pull/4215
## TPC-H and TPC-DS Performance
diff --git a/docs/source/user-guide/latest/iceberg.md
b/docs/source/user-guide/latest/iceberg.md
index 80859ba998..a8ea6f30ce 100644
--- a/docs/source/user-guide/latest/iceberg.md
+++ b/docs/source/user-guide/latest/iceberg.md
@@ -26,7 +26,7 @@ then serialized to Comet's native execution engine (see
[PR #2528](https://github.com/apache/datafusion-comet/pull/2528)).
The example below uses Spark's package downloader to retrieve Comet
$COMET_VERSION and Iceberg
-1.8.1, but Comet has been tested with Iceberg 1.5, 1.7, 1.8, 1.9, and 1.10.
The native Iceberg
+1.8.1, but Comet has been tested with Iceberg 1.5, 1.7, 1.8, 1.9, 1.10, and
1.11. The native Iceberg
reader is enabled by default. To disable it, set
`spark.comet.scan.icebergNative.enabled=false`.
The example uses the Spark 3.5 / Scala 2.12 build of Comet; substitute the
Comet artifact
@@ -63,7 +63,15 @@ The native Iceberg reader supports the following features:
**Table specifications:**
-- Iceberg table spec v1 and v2 (v3 will fall back to Spark)
+- Iceberg table spec v1, v2, and v3
+
+**Encryption:**
+
+- Encrypted v3 tables using 128-bit or 256-bit AES-GCM data keys (requires
Iceberg 1.11 or newer).
+ Iceberg-Java unwraps the key envelope on the driver during planning and
stores the plaintext data
+ key in each file's `key_metadata`, which the native reader uses directly, so
no KMS integration is
+ needed on the native side. Iceberg-Java also permits 192-bit data keys;
those tables fall back to
+ Spark (no AES-192-GCM in the underlying crypto).
**Schema and data types:**
@@ -156,17 +164,19 @@ These `s3.*` storage properties are not specific to the
Hive catalog shown here.
### Current limitations
-The following scenarios will fall back to Spark's native Iceberg reader:
+The following scenarios will fall back to the JVM Iceberg reader:
-- Iceberg table spec v3 scans
+- Iceberg table spec v4 or newer
+- v3 tables with columns that declare an initial default value
+- v3 column types the native reader cannot read (`variant`, `geometry`,
`geography`, `unknown`)
+- Encrypted tables with 192-bit data keys (no AES-192-GCM in the underlying
crypto)
+- Deletion vectors (v3 Puffin deletes); positional and equality deletes in
Parquet are supported
- Iceberg writes (reads are accelerated, writes use Spark)
- Tables backed by Avro or ORC data files (only Parquet is accelerated)
- Tables partitioned on `BINARY` or `DECIMAL` (with precision >28) columns
- Scans with residual filters using `truncate`, `bucket`, `year`, `month`,
`day`, or `hour`
transform functions (partition pruning still works, but row-level filtering
of these
transforms falls back)
-- Dynamic Partition Pruning under Adaptive Query Execution (non-AQE DPP is
supported);
- see [#3510](https://github.com/apache/datafusion-comet/issues/3510)
### Iceberg UDFs
diff --git a/native/Cargo.lock b/native/Cargo.lock
index 29f13df281..f4431f8551 100644
--- a/native/Cargo.lock
+++ b/native/Cargo.lock
@@ -232,9 +232,9 @@ checksum =
"d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "arrow"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471"
+checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -253,9 +253,9 @@ dependencies = [
[[package]]
name = "arrow-arith"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738"
+checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -267,9 +267,9 @@ dependencies = [
[[package]]
name = "arrow-array"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e"
+checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69"
dependencies = [
"ahash",
"arrow-buffer",
@@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "arrow-buffer"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0"
+checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5"
dependencies = [
"bytes",
"half",
@@ -298,9 +298,9 @@ dependencies = [
[[package]]
name = "arrow-cast"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f"
+checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -320,9 +320,9 @@ dependencies = [
[[package]]
name = "arrow-csv"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e94e8cf7e517657a52b91ea1263acf38c4ca62a84655d72458a3359b12ab97de"
+checksum = "af0dd6d90d1955e9f9a014c1e563ee8aeffc21909085d25623e1da44d96eca26"
dependencies = [
"arrow-array",
"arrow-cast",
@@ -335,9 +335,9 @@ dependencies = [
[[package]]
name = "arrow-data"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0"
+checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b"
dependencies = [
"arrow-buffer",
"arrow-schema",
@@ -348,9 +348,9 @@ dependencies = [
[[package]]
name = "arrow-ipc"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f"
+checksum = "29a908a11fcfb3fb2f6730f4ac15e367bc644e419155e96238f68cf3adde572b"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -364,9 +364,9 @@ dependencies = [
[[package]]
name = "arrow-json"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "205ca2119e6d679d5c133c6f30e68f027738d95ed948cf77677ea69c7800036b"
+checksum = "b8a96aed3931c076adee39ec2a40d8219fc7f09e79bcdaca1df16272993e1e14"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -389,9 +389,9 @@ dependencies = [
[[package]]
name = "arrow-ord"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0"
+checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -402,9 +402,9 @@ dependencies = [
[[package]]
name = "arrow-row"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c"
+checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -415,9 +415,9 @@ dependencies = [
[[package]]
name = "arrow-schema"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed"
+checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e"
dependencies = [
"bitflags 2.13.1",
"serde_core",
@@ -426,9 +426,9 @@ dependencies = [
[[package]]
name = "arrow-select"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222"
+checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad"
dependencies = [
"ahash",
"arrow-array",
@@ -440,9 +440,9 @@ dependencies = [
[[package]]
name = "arrow-string"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42"
+checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -3489,7 +3489,7 @@ dependencies = [
[[package]]
name = "iceberg"
version = "0.10.0"
-source =
"git+https://github.com/apache/iceberg-rust?rev=e8460ee#e8460eee8725c7f66d53e26b89b3fef578f90ce5"
+source =
"git+https://github.com/apache/iceberg-rust?rev=4532da8#4532da80f3930cdbda1cf2effe05b50165da9875"
dependencies = [
"aes-gcm",
"anyhow",
@@ -3545,7 +3545,7 @@ dependencies = [
[[package]]
name = "iceberg-storage-opendal"
version = "0.10.0"
-source =
"git+https://github.com/apache/iceberg-rust?rev=e8460ee#e8460eee8725c7f66d53e26b89b3fef578f90ce5"
+source =
"git+https://github.com/apache/iceberg-rust?rev=4532da8#4532da80f3930cdbda1cf2effe05b50165da9875"
dependencies = [
"anyhow",
"async-trait",
@@ -4786,9 +4786,9 @@ dependencies = [
[[package]]
name = "parquet"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dafa7d01085b62a47dd0c1829550a0a36710ea9c4fe358a05a85477cec8a908"
+checksum = "d298093b2dec60289dce0684c986d0f7679e9dd15771c2c65406e1aaf604a704"
dependencies = [
"ahash",
"arrow-array",
@@ -4826,9 +4826,9 @@ dependencies = [
[[package]]
name = "parquet-variant"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74c8db065291f088a2aad8ab831853eae1871c0d311c8d0b83bbc3b7e735d0fc"
+checksum = "3fc70e87931167a4a3fde2ee923023a0624367691e3fd503476a1084dda5a054"
dependencies = [
"arrow",
"arrow-schema",
@@ -4842,9 +4842,9 @@ dependencies = [
[[package]]
name = "parquet-variant-compute"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a530e8d5b5e14efcb39c9a6ec55432ad11f6afb7dc4455a79be0dc615fe3cc31"
+checksum = "823a9ecee8fd83a68f7165ef13acc840c4d7ed995838ba99d4714b20a2b5e780"
dependencies = [
"arrow",
"arrow-schema",
@@ -4859,9 +4859,9 @@ dependencies = [
[[package]]
name = "parquet-variant-json"
-version = "58.3.0"
+version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00ed89908289f67caa2ca078f9ff9aacd6229a313ec92b12bf4f48f613dc2b97"
+checksum = "6f37a91177e2dddb10333546952fcf2b7674b97ebd9449432f24b883d6ea8108"
dependencies = [
"arrow-schema",
"base64",
diff --git a/native/Cargo.toml b/native/Cargo.toml
index 313a0b43be..fd7d7d3a66 100644
--- a/native/Cargo.toml
+++ b/native/Cargo.toml
@@ -34,10 +34,10 @@ edition = "2021"
rust-version = "1.88"
[workspace.dependencies]
-arrow = { version = "58.3.0", features = ["prettyprint", "ffi", "chrono-tz"] }
+arrow = { version = "58.4.0", features = ["prettyprint", "ffi", "chrono-tz"] }
async-trait = { version = "0.1" }
bytes = { version = "1.11.1" }
-parquet = { version = "58.3.0", default-features = false, features =
["experimental"] }
+parquet = { version = "58.4.0", default-features = false, features =
["experimental"] }
datafusion = { version = "54.1.0", default-features = false, features =
["unicode_expressions", "crypto_expressions", "nested_expressions", "parquet"] }
datafusion-datasource = { version = "54.1.0" }
datafusion-physical-expr-adapter = { version = "54.1.0" }
@@ -58,8 +58,8 @@ object_store = { version = "0.13.2", features = ["gcp",
"azure", "aws", "http"]
url = "2.2"
aws-config = "1.8.18"
aws-credential-types = "1.2.13"
-iceberg = { git = "https://github.com/apache/iceberg-rust", rev = "e8460ee" }
-iceberg-storage-opendal = { git = "https://github.com/apache/iceberg-rust",
rev = "e8460ee", features = ["opendal-memory", "opendal-fs", "opendal-s3",
"opendal-gcs", "opendal-oss", "opendal-azdls"] }
+iceberg = { git = "https://github.com/apache/iceberg-rust", rev = "4532da8" }
+iceberg-storage-opendal = { git = "https://github.com/apache/iceberg-rust",
rev = "4532da8", features = ["opendal-memory", "opendal-fs", "opendal-s3",
"opendal-gcs", "opendal-oss", "opendal-azdls"] }
reqsign-core = "3"
[profile.release]
diff --git a/native/core/src/execution/operators/iceberg_scan.rs
b/native/core/src/execution/operators/iceberg_scan.rs
index 7c95c6a53b..d77b208964 100644
--- a/native/core/src/execution/operators/iceberg_scan.rs
+++ b/native/core/src/execution/operators/iceberg_scan.rs
@@ -633,6 +633,7 @@ mod tests {
use std::io::Write;
use std::sync::Arc;
+ use iceberg::encryption::StandardKeyMetadata;
use iceberg::io::{FileIO, FileIOBuilder};
use iceberg::scan::{FileScanTask, FileScanTaskDeleteFile};
use iceberg::spec::{DataContentType, DataFileFormat, Schema};
@@ -738,4 +739,60 @@ mod tests {
.await
.unwrap();
}
+
+ fn from_hex(s: &str) -> Vec<u8> {
+ assert!(s.len().is_multiple_of(2), "odd-length hex string");
+ (0..s.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("invalid
hex"))
+ .collect()
+ }
+
+ // iceberg-rust encodes and decodes its own StandardKeyMetadata
identically. Confirms the API
+ // is present after the rev bump and the [version byte][Avro datum] format
round-trips.
+ #[test]
+ fn standard_key_metadata_roundtrips() {
+ let key: Vec<u8> = (0u8..16).collect();
+ let aad: &[u8] = b"comet-aad-prefix";
+ let km = StandardKeyMetadata::try_new(&key)
+ .unwrap()
+ .with_aad_prefix(aad);
+
+ let encoded = km.encode().unwrap();
+ assert_eq!(encoded[0], 0x01, "expected StandardKeyMetadata V1 marker");
+
+ let decoded = StandardKeyMetadata::decode(&encoded).unwrap();
+ assert_eq!(decoded.encryption_key().as_bytes(), key.as_slice());
+ assert_eq!(decoded.aad_prefix(), Some(aad));
+ }
+
+ // Cross-language gate for the encrypted-read passthrough plan:
iceberg-rust must decode the
+ // exact StandardKeyMetadata bytes that Iceberg-Java writes into a data
file's key_metadata,
+ // because Comet forwards those bytes verbatim (no re-encoding, no KMS)
into
+ // FileScanTask::key_metadata. This fixture is a real Iceberg-Java blob
produced by
+ // StandardEncryptionManager. To regenerate (e.g. after a wire-format
change), print the bytes
+ // from CometIcebergEncryptionSuite's plaintext-DEK test and paste them
here.
+ const JAVA_KEY_METADATA_HEX: &str =
+
"012084f49fba77f8ff1da0c115d1e46563cc0220f1d31d62b68808b469eb99fe9c57096000";
+ const JAVA_DEK_HEX: &str = "84f49fba77f8ff1da0c115d1e46563cc";
+
+ #[test]
+ fn decodes_java_produced_key_metadata() {
+ let blob = from_hex(JAVA_KEY_METADATA_HEX);
+ let expected_dek = from_hex(JAVA_DEK_HEX);
+
+ let decoded = StandardKeyMetadata::decode(&blob)
+ .expect("iceberg-rust failed to decode a Java-produced
StandardKeyMetadata blob");
+ assert_eq!(
+ decoded.encryption_key().as_bytes(),
+ expected_dek.as_slice(),
+ "DEK recovered by Rust differs from the Java plaintext DEK"
+ );
+ // The Java blob carries a 16-byte AAD prefix; confirm the
optional-field union decodes too.
+ assert_eq!(
+ decoded.aad_prefix().map(|a| a.len()),
+ Some(16),
+ "expected a 16-byte AAD prefix from the Java blob"
+ );
+ }
}
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index e43daed9a5..a4aedfc991 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -3706,7 +3706,9 @@ fn parse_file_scan_tasks_from_common(
} else {
Some(del.equality_ids.clone())
},
- key_metadata: None,
+ // Plaintext StandardKeyMetadata forwarded verbatim from the
JVM; decoded by
+ // iceberg-rust with no KMS unwrap. None for unencrypted
delete files.
+ key_metadata:
del.key_metadata.clone().map(Vec::into_boxed_slice),
})
})
.collect::<Result<Vec<_>, ExecutionError>>()?;
@@ -3849,7 +3851,9 @@ fn parse_file_scan_tasks_from_common(
partition_spec,
name_mapping,
case_sensitive: false,
- key_metadata: None,
+ // Plaintext StandardKeyMetadata forwarded verbatim from the
JVM; decoded by
+ // iceberg-rust with no KMS unwrap. None for unencrypted data
files.
+ key_metadata:
proto_task.key_metadata.clone().map(Vec::into_boxed_slice),
})
})
.collect();
diff --git a/native/proto/src/proto/operator.proto
b/native/proto/src/proto/operator.proto
index 439c9613a4..5493d921b8 100644
--- a/native/proto/src/proto/operator.proto
+++ b/native/proto/src/proto/operator.proto
@@ -318,6 +318,11 @@ message IcebergFileScanTask {
// Total file size from the manifest entry, used to skip stat/HEAD calls
uint64 file_size_in_bytes = 5;
+ // Serialized StandardKeyMetadata (plaintext DEK + AAD prefix) for an
encrypted data file, taken
+ // verbatim from Iceberg's DataFile.keyMetadata(). Absent for unencrypted
tables. iceberg-rust
+ // decodes this directly; no KMS unwrap is needed on the native side.
+ optional bytes key_metadata = 6;
+
// Indices into IcebergScan deduplication pools
uint32 schema_idx = 15;
optional uint32 partition_type_idx = 16;
@@ -344,6 +349,15 @@ message IcebergDeleteFile {
// Equality field IDs (empty for positional deletes)
repeated int32 equality_ids = 4;
+
+ // Fields 5-7 are reserved for the deletion-vector coordinates on the
dv-read branch
+ // (referenced_data_file / content_offset / content_size_in_bytes) so the
two features can merge
+ // in either order without a tag collision.
+ reserved 5, 6, 7;
+
+ // Serialized StandardKeyMetadata for an encrypted delete file, from
DeleteFile.keyMetadata().
+ // Absent for unencrypted tables. Decoded directly by iceberg-rust; no KMS
unwrap needed.
+ optional bytes key_metadata = 8;
}
message Projection {
diff --git a/spark/pom.xml b/spark/pom.xml
index cc3f278ee3..fbe059c739 100644
--- a/spark/pom.xml
+++ b/spark/pom.xml
@@ -107,6 +107,13 @@ under the License.
<artifactId>spark-hadoop-cloud_${scala.binary.version}</artifactId>
<classifier>tests</classifier>
</dependency>
+ <dependency>
+ <!-- Test-only: provides an embedded Hive metastore so Iceberg's
type=hive catalog can
+ create encrypted tables (only Hive wires the KMS-backed
EncryptionManager). Version and
+ scope come from the parent pom's dependencyManagement. -->
+ <groupId>org.apache.spark</groupId>
+ <artifactId>spark-hive_${scala.binary.version}</artifactId>
+ </dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
diff --git
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
index 1a5cea7c5c..72e885e834 100644
--- a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
@@ -51,6 +51,7 @@ object IcebergReflection extends Logging {
val UNBOUND_PREDICATE = "org.apache.iceberg.expressions.UnboundPredicate"
val SPARK_BATCH_QUERY_SCAN =
"org.apache.iceberg.spark.source.SparkBatchQueryScan"
val SPARK_STAGED_SCAN = "org.apache.iceberg.spark.source.SparkStagedScan"
+ val SPARK_SCHEMA_UTIL = "org.apache.iceberg.spark.SparkSchemaUtil"
}
/**
@@ -771,6 +772,68 @@ object IcebergReflection extends Logging {
unsupportedTypes.toList
}
+
+ /**
+ * Returns the names of schema columns (including nested struct/list/map
fields) that declare a
+ * V3 initial-default value. iceberg-rust does not synthesize default values
for columns absent
+ * from a data file, so reads projecting such columns must fall back. Throws
on reflection
+ * failure so the caller can fall back rather than risk a native crash.
+ */
+ def columnsWithInitialDefault(schema: Any): List[String] = {
+ import scala.jdk.CollectionConverters._
+ val columns =
+
schema.getClass.getMethod("columns").invoke(schema).asInstanceOf[java.util.List[_]]
+ columns.asScala.flatMap(walkFieldForDefault).toList
+ }
+
+ private def walkFieldForDefault(field: Any): List[String] = {
+ import scala.jdk.CollectionConverters._
+ val name =
field.getClass.getMethod("name").invoke(field).asInstanceOf[String]
+ val here =
+ if (field.getClass.getMethod("initialDefault").invoke(field) != null)
List(name) else Nil
+ val fieldType = field.getClass.getMethod("type").invoke(field)
+ val nested =
+ if
(fieldType.getClass.getMethod("isNestedType").invoke(fieldType).asInstanceOf[Boolean])
{
+ val nestedType =
fieldType.getClass.getMethod("asNestedType").invoke(fieldType)
+ val fields =
+ nestedType.getClass
+ .getMethod("fields")
+ .invoke(nestedType)
+ .asInstanceOf[java.util.List[_]]
+ fields.asScala.flatMap(walkFieldForDefault).toList
+ } else {
+ Nil
+ }
+ here ++ nested
+ }
+
+ /**
+ * Converts an Iceberg `Schema` to the Spark `StructType` it reads as, via
+ * `SparkSchemaUtil.convert`. Comet serializes the whole table/scan schema
to native (not just
+ * projected columns), so callers use this to run the schema through Comet's
existing type
+ * allow-list and fall back if any column is a type the native reader does
not support (e.g.
+ * variant). Throws on reflection failure so the caller can fall back.
+ */
+ def toSparkSchema(schema: Any): org.apache.spark.sql.types.StructType = {
+ val sparkSchemaUtil = loadClass(ClassNames.SPARK_SCHEMA_UTIL)
+ val schemaClass = loadClass(ClassNames.SCHEMA)
+ val convert = sparkSchemaUtil.getMethod("convert", schemaClass)
+ convert
+ .invoke(null, schema.asInstanceOf[AnyRef])
+ .asInstanceOf[org.apache.spark.sql.types.StructType]
+ }
+
+ /**
+ * The configured AES data-key length in bytes for an encrypted table
(Iceberg's
+ * `encryption.data-key-length`, default 16), or None if the table is not
encrypted. Comet's
+ * native Parquet reader supports 128-bit (16-byte) and 256-bit (32-byte)
keys but not 192-bit
+ * (the underlying crypto has no AES-192-GCM), so callers fall back for
anything else. Throws on
+ * reflection failure so the caller can fall back.
+ */
+ def encryptionDataKeyLength(table: Any): Option[Int] =
+ getTableProperties(table).filter(_.containsKey("encryption.key-id")).map {
props =>
+
Option(props.get("encryption.data-key-length")).map(_.toInt).getOrElse(16)
+ }
}
/**
diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
index 59331129d5..5e451b382a 100644
--- a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
+++ b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
@@ -111,7 +111,12 @@ case class CometScanRule(session: SparkSession)
"all_entries",
"all_manifests")
- metadataTableSuffix.exists(suffix =>
scanExec.table.name().endsWith(suffix))
+ // Match case-insensitively: metadata tables surface lowercase via the
path form
+ // (...metadata.json#all_manifests) but uppercase via the
catalog-identifier form
+ // (db.table.ALL_DATA_FILES), and the latter must hit this gate too
rather than fall through
+ // to reflection that fails on the metadata-table class.
+ val name = scanExec.table.name().toLowerCase(Locale.ROOT)
+ metadataTableSuffix.exists(name.endsWith)
}
val fullPlan = plan
@@ -462,12 +467,18 @@ case class CometScanRule(session: SparkSession)
// Check Iceberg table format version
- val formatVersionSupported =
IcebergReflection.getFormatVersion(metadata.table) match {
- case Some(formatVersion) =>
- if (formatVersion > 2) {
+ // V3 adds column types iceberg-rust cannot read (variant, geometry,
geography, unknown)
+ // and column default values; those are handled by the allow-list and
default-value checks
+ // below, which fall back per-table. This gate only bounds the format
version. Deletion
+ // vectors (a V3 delete feature) are handled by the delete-file gate,
which still falls back
+ // for non-Parquet (Puffin) deletes since native deletion-vector reads
are not yet wired.
+ val formatVersion = IcebergReflection.getFormatVersion(metadata.table)
+ val formatVersionSupported = formatVersion match {
+ case Some(v) =>
+ if (v > 3) {
fallbackReasons += "Iceberg table format version " +
- s"$formatVersion is not supported. " +
- "Comet only supports Iceberg table format V1 and V2"
+ s"$v is not supported. " +
+ "Comet supports Iceberg table format V1, V2, and V3"
false
} else {
true
@@ -477,6 +488,68 @@ case class CometScanRule(session: SparkSession)
false
}
+ // Column default values are a V3 feature, so V1/V2 tables cannot have
them and need no
+ // check. Only inspect V3 tables. iceberg-rust does not synthesize a
V3 initial-default for
+ // a column absent from a data file, so a scan projecting such a
column must fall back. A V3
+ // table implies an Iceberg version new enough to expose
initialDefault(), so a reflection
+ // failure here is unexpected and also falls back rather than risk a
crash.
+ val defaultValuesSupported =
+ if (!formatVersion.exists(_ >= 3)) {
+ true
+ } else {
+ try {
+ val defaulted =
IcebergReflection.columnsWithInitialDefault(metadata.scanSchema)
+ if (defaulted.nonEmpty) {
+ fallbackReasons += "Iceberg column(s) with V3 default values
are not yet " +
+ s"supported by Comet's native reader:
${defaulted.mkString(", ")}"
+ false
+ } else {
+ true
+ }
+ } catch {
+ case e: Exception =>
+ fallbackReasons += "Iceberg reflection failure: could not
verify V3 default " +
+ s"values: ${e.getMessage}"
+ false
+ }
+ }
+
+ // Comet serializes the whole table/scan schema to native, not just
projected columns, so a
+ // type the native reader does not support (e.g. variant) breaks the
scan even when that
+ // column is not projected. The readSchema allow-list only covers
projected columns, so run
+ // the same allow-list over the full schema Comet may serialize.
Reflection failure also
+ // falls back.
+ val schemaTypesSupported =
+ try {
+ val fullSchema =
IcebergReflection.toSparkSchema(metadata.tableSchema)
+ typeChecker.isSchemaSupported(fullSchema, fallbackReasons)
+ } catch {
+ case e: Exception =>
+ fallbackReasons += "Iceberg reflection failure: could not verify
column " +
+ s"types: ${e.getMessage}"
+ false
+ }
+
+ // The native Parquet reader decrypts 128-bit and 256-bit AES-GCM data
keys (16- or
+ // 32-byte). 192-bit is unsupported because the underlying crypto has
no AES-192-GCM. Fall
+ // back for anything else. None means the table is unencrypted.
Reflection failure also
+ // falls back.
+ val encryptionKeyLengthSupported =
+ try {
+ IcebergReflection.encryptionDataKeyLength(metadata.table) match {
+ case Some(len) if len != 16 && len != 32 =>
+ fallbackReasons += s"Iceberg table encryption with a ${len *
8}-bit data key is " +
+ "not yet supported by Comet's native reader (only 128-bit
and 256-bit AES-GCM)"
+ false
+ case _ => true
+ }
+ } catch {
+ case e: Exception =>
+ fallbackReasons += "Iceberg reflection failure: could not verify
encryption key " +
+ s"length: ${e.getMessage}"
+ false
+ }
+
// Single-pass validation of all FileScanTasks
val taskValidation =
try {
@@ -713,6 +786,7 @@ case class CometScanRule(session: SparkSession)
}
if (schemaSupported && fileIOCompatible && formatVersionSupported &&
+ defaultValuesSupported && schemaTypesSupported &&
encryptionKeyLengthSupported &&
taskValidation.allParquet && allSupportedFilesystems &&
partitionTypesSupported &&
complexTypePredicatesSupported && transformFunctionsSupported &&
deleteFileTypesSupported && dppSubqueriesSupported) {
diff --git
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
index 247a2f9245..c2b792c36c 100644
---
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
+++
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
@@ -231,6 +231,22 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
builder.build()
}
+ /**
+ * An encrypted ContentFile's plaintext StandardKeyMetadata blob as a
protobuf ByteString, or
+ * None for an unencrypted file (keyMetadata() returns null). `method` is
the cached
+ * ContentFile.keyMetadata() accessor. A reflection failure here is
intentionally not caught: by
+ * serde time the plan is already committed to the native scan, so there is
no fallback, and
+ * silently dropping the key of an encrypted file would fail obscurely in
the native reader.
+ */
+ private def keyMetadataBytes(
+ method: java.lang.reflect.Method,
+ contentFile: Any): Option[com.google.protobuf.ByteString] =
+ method.invoke(contentFile) match {
+ case buf: java.nio.ByteBuffer if buf.remaining() > 0 =>
+ Some(com.google.protobuf.ByteString.copyFrom(buf.duplicate()))
+ case _ => None
+ }
+
/**
* Extracts delete files from an Iceberg FileScanTask as a list (for
deduplication).
*
@@ -244,6 +260,8 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
fileScanTaskClass: Class[_]): Seq[OperatorOuterClass.IcebergDeleteFile]
= {
try {
val deleteFileClass =
IcebergReflection.loadClass(IcebergReflection.ClassNames.DELETE_FILE)
+ // keyMetadata() is declared on ContentFile; present across all
supported Iceberg versions.
+ val keyMetadataMethod = contentFileClass.getMethod("keyMetadata")
val deletes = IcebergReflection.getDeleteFilesFromTask(task,
fileScanTaskClass)
@@ -295,6 +313,10 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
case _: Exception =>
}
+ // Encrypted delete files carry a plaintext StandardKeyMetadata blob;
forward it verbatim.
+ // Unencrypted delete files leave the field unset.
+ keyMetadataBytes(keyMetadataMethod,
deleteFile).foreach(deleteBuilder.setKeyMetadata)
+
deleteBuilder.build()
}.toSeq
} catch {
@@ -884,6 +906,9 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
val lengthMethod = contentScanTaskClass.getMethod("length")
val residualMethod = contentScanTaskClass.getMethod("residual")
val fileSizeInBytesMethod = contentFileClass.getMethod("fileSizeInBytes")
+ // keyMetadata() is declared on ContentFile (present across all supported
Iceberg versions).
+ // For encrypted tables it returns the plaintext StandardKeyMetadata blob;
null otherwise.
+ val keyMetadataMethod = contentFileClass.getMethod("keyMetadata")
val taskSchemaMethod = fileScanTaskClass.getMethod("schema")
val toJsonMethod = schemaParserClass.getMethod("toJson", schemaClass)
toJsonMethod.setAccessible(true)
@@ -941,6 +966,10 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
fileSizeInBytesMethod.invoke(dataFile).asInstanceOf[Long]
taskBuilder.setFileSizeInBytes(fileSizeInBytes)
+ // Encrypted data files carry a plaintext StandardKeyMetadata
blob; forward it
+ // verbatim for iceberg-rust to decode. Unencrypted files
leave the field unset.
+ keyMetadataBytes(keyMetadataMethod,
dataFile).foreach(taskBuilder.setKeyMetadata)
+
val taskSchema = taskSchemaMethod.invoke(task)
val deletes =
diff --git a/spark/src/test/scala/org/apache/comet/CometIcebergTestBase.scala
b/spark/src/test/scala/org/apache/comet/CometIcebergTestBase.scala
index 33de4f3705..07ac91d90c 100644
--- a/spark/src/test/scala/org/apache/comet/CometIcebergTestBase.scala
+++ b/spark/src/test/scala/org/apache/comet/CometIcebergTestBase.scala
@@ -72,7 +72,7 @@ trait CometIcebergTestBase {
finally deleteRecursively(dir)
}
- private def deleteRecursively(file: File): Unit = {
+ protected def deleteRecursively(file: File): Unit = {
if (file.isDirectory) file.listFiles().foreach(deleteRecursively)
file.delete()
}
diff --git
a/spark/src/test/spark-4.1/org/apache/comet/CometIcebergEncryptionSuite.scala
b/spark/src/test/spark-4.1/org/apache/comet/CometIcebergEncryptionSuite.scala
new file mode 100644
index 0000000000..1b221bf454
--- /dev/null
+++
b/spark/src/test/spark-4.1/org/apache/comet/CometIcebergEncryptionSuite.scala
@@ -0,0 +1,261 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.comet
+
+import java.io.File
+import java.nio.ByteBuffer
+import java.util.Collections
+
+import org.apache.iceberg.Files
+import org.apache.iceberg.encryption.{EncryptedKey,
NativeEncryptionKeyMetadata, StandardEncryptionManager}
+import org.apache.iceberg.util.ByteBuffers
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.CometTestBase
+import org.apache.spark.sql.comet.CometIcebergNativeScanExec
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+
+import org.apache.comet.iceberg.CometTestKMS
+
+/**
+ * Encryption-specific Iceberg tests. Lives in the spark-4.1 source set
because Iceberg table
+ * encryption is a V3 feature and the encryption APIs (KeyManagementClient,
StandardEncryption
+ * manager) are only public from Iceberg 1.11, which Comet pairs with Spark
4.1.
+ */
+class CometIcebergEncryptionSuite
+ extends CometTestBase
+ with CometIcebergTestBase
+ with AdaptiveSparkPlanHelper {
+
+ // A Hive catalog is required for an actual encrypted table: it is the only
Iceberg catalog that
+ // wires the KMS-backed EncryptionManager in 1.11 (Hadoop/REST write
plaintext). Point Iceberg's
+ // type=hive catalog at an in-memory Derby metastore so no external HMS is
needed. Set at session
+ // creation because Iceberg builds its HiveConf from the Hadoop
configuration, which reflects
+ // spark.hadoop.* only at context startup, not from runtime withSQLConf.
+ //
+ // lazy so the temp dir is created at session init, not at construction: CI
runs tests with
+ // -DwildcardSuites, which puts ScalaTest in discovery mode and instantiates
every suite before
+ // any test executes. createTempDirectory does not create its parent
(java.io.tmpdir, pinned to
+ // target/tmp by the pom), which does not exist yet on a fresh checkout, so
an eager val aborts
+ // discovery with NoSuchFileException. createDirectories makes the parent up
front.
+ private lazy val hiveWarehouse = {
+ val base = java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"))
+ java.nio.file.Files.createDirectories(base)
+ val dir = java.nio.file.Files.createTempDirectory(base,
"comet-hive-warehouse").toFile
+ createdWarehouse = dir
+ dir
+ }
+
+ // Captured on init rather than read from the lazy val so a run that never
touched it (all tests
+ // skipped) creates nothing to delete.
+ private var createdWarehouse: File = _
+
+ override protected def afterAll(): Unit = {
+ try if (createdWarehouse != null) deleteRecursively(createdWarehouse)
+ finally super.afterAll()
+ }
+
+ override protected def sparkConf: SparkConf = {
+ super.sparkConf
+ .set(
+ "spark.hadoop.javax.jdo.option.ConnectionURL",
+ "jdbc:derby:memory:comet_iceberg_encryption;create=true")
+ .set("spark.hadoop.datanucleus.schema.autoCreateAll", "true")
+ .set("spark.hadoop.hive.metastore.schema.verification", "false")
+ // Hive's default metastore connection pool is BoneCP, which Spark's
Hive fork does not ship
+ // (NoClassDefFoundError: com/jolbox/bonecp/BoneCPConfig). This config
drives both the
+ // DataNucleus ObjectStore and the TxnHandler pool, so DBCP steers the
embedded metastore off
+ // BoneCP. Mirrors Iceberg's TestHiveMetastore.initConf.
+ .set("spark.hadoop.datanucleus.connectionPoolingType", "DBCP")
+ .set("spark.hadoop.hive.in.test", "true")
+ // Skip Iceberg's HMS table lock. The embedded metastore's ACID
TxnHandler rejects Iceberg's
+ // lock request ("Bug: operationType=UNSET"); disabling it uses the
lock-free commit path
+ // (atomic alter-table with an expected-parameter check), which is all
this single-writer test
+ // needs.
+ .set("spark.hadoop.iceberg.engine.hive.lock-enabled", "false")
+ .set("spark.hadoop.hive.metastore.warehouse.dir",
hiveWarehouse.toURI.toString)
+ .set("spark.sql.catalog.hive_cat",
"org.apache.iceberg.spark.SparkCatalog")
+ .set("spark.sql.catalog.hive_cat.type", "hive")
+ .set("spark.sql.catalog.hive_cat.warehouse",
hiveWarehouse.toURI.toString)
+ .set("spark.sql.catalog.hive_cat.encryption.kms-impl",
classOf[CometTestKMS].getName)
+ }
+
+ // End-to-end: an encrypted V3 table is read through Comet's native Iceberg
scan and matches
+ // Spark. This is the guard that the encrypted path stays native; Iceberg's
own TestTableEncryption
+ // (run under Comet in CI) only asserts correctness, not that the native
scan was used.
+ test("encrypted V3 table is read natively and matches Spark") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ assume(icebergVersionAtLeast(1, 11), "Iceberg table encryption requires
Iceberg 1.11+")
+
+ withSQLConf(
+ CometConf.COMET_ENABLED.key -> "true",
+ CometConf.COMET_EXEC_ENABLED.key -> "true",
+ CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+ val table = "hive_cat.db.encrypted"
+ // The Hive catalog does not auto-create namespaces the way the Hadoop
catalog does.
+ spark.sql("CREATE NAMESPACE IF NOT EXISTS hive_cat.db")
+ spark.sql(s"DROP TABLE IF EXISTS $table")
+ spark.sql(s"""
+ CREATE TABLE $table (id INT, name STRING, value DOUBLE) USING iceberg
+ TBLPROPERTIES ('format-version' = '3', 'encryption.key-id' =
'${CometTestKMS.MasterKeyId}')
+ """)
+ spark.sql(s"""
+ INSERT INTO $table VALUES (1, 'Alice', 10.5), (2, 'Bob', 20.3), (3,
'Charlie', 30.7)
+ """)
+
+ // Guard against a silently-plaintext table (e.g. catalog not wiring
encryption): a genuinely
+ // encrypted table records a plaintext DEK blob per data file in
key_metadata.
+ val keyMetadatas = spark
+ .sql(s"SELECT key_metadata FROM $table.files WHERE content = 0")
+ .collect()
+ .flatMap(r => Option(r.getAs[Array[Byte]]("key_metadata")))
+ assert(
+ keyMetadatas.nonEmpty,
+ "expected encryption to populate key_metadata; the Hive catalog may
not have wired the KMS")
+
+ val (_, cometPlan) = checkSparkAnswer(s"SELECT * FROM $table ORDER BY
id")
+ assert(
+ collect(cometPlan) { case s: CometIcebergNativeScanExec => s
}.nonEmpty,
+ "expected the encrypted read to run natively but found no
CometIcebergNativeScanExec:\n" +
+ cometPlan)
+
+ spark.sql(s"DROP TABLE $table")
+ }
+ }
+
+ // Confirms the assumption that native encrypted reads rely on: an Iceberg
data file's
+ // key_metadata carries a PLAINTEXT data encryption key, not a KMS-wrapped
one. Iceberg mints a
+ // fresh DEK per file in StandardEncryptionManager.encrypt() and records it
plaintext in
+ // key_metadata (the manifest that holds it is itself encrypted). The KMS
only wraps the KEK /
+ // manifest-list keys, never the per-file DEK. Runs without a catalog.
+ test("data file key_metadata carries a plaintext DEK that round-trips") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ assume(icebergVersionAtLeast(1, 11), "Iceberg table encryption requires
Iceberg 1.11+")
+
+ val dataKeyLength = 16
+ val mgr = new StandardEncryptionManager(
+ Collections.emptyList[EncryptedKey](),
+ CometTestKMS.MasterKeyId,
+ dataKeyLength,
+ new CometTestKMS)
+
+ withTempDir { dir =>
+ // encrypt() lazily generates the per-file DEK + AAD prefix; no bytes
are written.
+ val keyMetadata =
+ mgr.encrypt(Files.localOutput(new java.io.File(dir,
"data.parquet"))).keyMetadata()
+
+ val dek = keyMetadata.encryptionKey()
+ assert(
+ dek.remaining() == dataKeyLength,
+ s"expected a plaintext $dataKeyLength-byte DEK but got
${dek.remaining()} bytes")
+ val aad = keyMetadata.aadPrefix()
+ assert(aad != null && aad.remaining() > 0, "expected a non-empty AAD
prefix")
+
+ // buffer() is the serialized StandardKeyMetadata blob stored in the
manifest's key_metadata
+ // field, and the exact bytes Comet would forward to iceberg-rust. It
must start with the V1
+ // marker and round-trip back to the same DEK. StandardKeyMetadata.parse
is package-private,
+ // so reach it reflectively, but read the result through the public
NativeEncryptionKeyMetadata
+ // interface (the concrete class is not accessible). iceberg-rust
decodes this same format.
+ val blob = ByteBuffers.toByteArray(keyMetadata.buffer())
+ assert(blob(0) == 1, s"unexpected key_metadata version byte: ${blob(0)}")
+
+ val kmClass =
Class.forName("org.apache.iceberg.encryption.StandardKeyMetadata")
+ val parse = kmClass.getDeclaredMethod("parse", classOf[ByteBuffer])
+ parse.setAccessible(true)
+ val parsed =
+ parse.invoke(null,
ByteBuffer.wrap(blob)).asInstanceOf[NativeEncryptionKeyMetadata]
+ val roundTripped = ByteBuffers.toByteArray(parsed.encryptionKey())
+
+ assert(
+ java.util.Arrays.equals(ByteBuffers.toByteArray(dek), roundTripped),
+ "DEK did not survive the StandardKeyMetadata encode/parse round-trip")
+ }
+ }
+
+ // A 256-bit (32-byte) AES-GCM data key is supported natively, so the read
must run through Comet
+ // and match Spark.
+ test("encrypted V3 table with a 256-bit data key is read natively and
matches Spark") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ assume(icebergVersionAtLeast(1, 11), "Iceberg table encryption requires
Iceberg 1.11+")
+
+ withSQLConf(
+ CometConf.COMET_ENABLED.key -> "true",
+ CometConf.COMET_EXEC_ENABLED.key -> "true",
+ CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+ val table = "hive_cat.db.encrypted_256"
+ spark.sql("CREATE NAMESPACE IF NOT EXISTS hive_cat.db")
+ spark.sql(s"DROP TABLE IF EXISTS $table")
+ spark.sql(s"""
+ CREATE TABLE $table (id INT, name STRING) USING iceberg
+ TBLPROPERTIES ('format-version' = '3', 'encryption.key-id' =
'${CometTestKMS.MasterKeyId}',
+ 'encryption.data-key-length' = '32')
+ """)
+ spark.sql(s"INSERT INTO $table VALUES (1, 'Alice'), (2, 'Bob')")
+
+ val keyMetadatas = spark
+ .sql(s"SELECT key_metadata FROM $table.files WHERE content = 0")
+ .collect()
+ .flatMap(r => Option(r.getAs[Array[Byte]]("key_metadata")))
+ assert(
+ keyMetadatas.nonEmpty,
+ "expected encryption to populate key_metadata; the Hive catalog may
not have wired the KMS")
+
+ val (_, cometPlan) = checkSparkAnswer(s"SELECT * FROM $table ORDER BY
id")
+ assert(
+ collect(cometPlan) { case s: CometIcebergNativeScanExec => s
}.nonEmpty,
+ "expected the 256-bit encrypted read to run natively but found no " +
+ s"CometIcebergNativeScanExec:\n$cometPlan")
+
+ spark.sql(s"DROP TABLE $table")
+ }
+ }
+
+ // A 192-bit data key is unsupported (no AES-192-GCM in the underlying
crypto), so the table must
+ // fall back to Spark while still returning correct results.
+ test("encrypted V3 table with a 192-bit data key falls back to Spark") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ assume(icebergVersionAtLeast(1, 11), "Iceberg table encryption requires
Iceberg 1.11+")
+
+ withSQLConf(
+ CometConf.COMET_ENABLED.key -> "true",
+ CometConf.COMET_EXEC_ENABLED.key -> "true",
+ CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+ val table = "hive_cat.db.encrypted_192"
+ spark.sql("CREATE NAMESPACE IF NOT EXISTS hive_cat.db")
+ spark.sql(s"DROP TABLE IF EXISTS $table")
+ spark.sql(s"""
+ CREATE TABLE $table (id INT, name STRING) USING iceberg
+ TBLPROPERTIES ('format-version' = '3', 'encryption.key-id' =
'${CometTestKMS.MasterKeyId}',
+ 'encryption.data-key-length' = '24')
+ """)
+ spark.sql(s"INSERT INTO $table VALUES (1, 'Alice'), (2, 'Bob')")
+
+ val (_, cometPlan) = checkSparkAnswer(s"SELECT * FROM $table ORDER BY
id")
+ assert(
+ collect(cometPlan) { case s: CometIcebergNativeScanExec => s }.isEmpty,
+ "expected a 192-bit encrypted table to fall back to Spark but it ran
natively:\n" +
+ cometPlan)
+
+ spark.sql(s"DROP TABLE $table")
+ }
+ }
+}
diff --git
a/spark/src/test/spark-4.1/org/apache/comet/iceberg/CometTestKMS.scala
b/spark/src/test/spark-4.1/org/apache/comet/iceberg/CometTestKMS.scala
new file mode 100644
index 0000000000..b9317af6b2
--- /dev/null
+++ b/spark/src/test/spark-4.1/org/apache/comet/iceberg/CometTestKMS.scala
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.comet.iceberg
+
+import java.nio.ByteBuffer
+import java.util
+
+import org.apache.iceberg.encryption.Ciphers
+import org.apache.iceberg.encryption.KeyManagementClient
+import org.apache.iceberg.util.ByteBuffers
+
+/**
+ * In-memory KMS for exercising Iceberg table encryption in tests. Iceberg
instantiates this by
+ * class name via the `encryption.kms-impl` catalog property, so it needs a
public no-arg
+ * constructor. Wrapping/unwrapping mirrors Iceberg's own MemoryMockKMS
(AES-GCM under a fixed
+ * master key); it is self-consistent, which is all Iceberg requires. Not for
production use.
+ */
+class CometTestKMS extends KeyManagementClient {
+
+ override def wrapKey(key: ByteBuffer, wrappingKeyId: String): ByteBuffer = {
+ val masterKey = CometTestKMS.masterKey(wrappingKeyId)
+ val encryptor = new Ciphers.AesGcmEncryptor(masterKey)
+ ByteBuffer.wrap(encryptor.encrypt(ByteBuffers.toByteArray(key), null))
+ }
+
+ override def unwrapKey(wrappedKey: ByteBuffer, wrappingKeyId: String):
ByteBuffer = {
+ val masterKey = CometTestKMS.masterKey(wrappingKeyId)
+ val decryptor = new Ciphers.AesGcmDecryptor(masterKey)
+ ByteBuffer.wrap(decryptor.decrypt(ByteBuffers.toByteArray(wrappedKey),
null))
+ }
+
+ override def initialize(properties: util.Map[String, String]): Unit = {}
+}
+
+object CometTestKMS {
+ // 16-byte master key; keyed by the id referenced in the table's
`encryption.key-id` property.
+ val MasterKeyId = "keyA"
+ private val MasterKey = "0123456789012345".getBytes("UTF-8")
+
+ private def masterKey(wrappingKeyId: String): Array[Byte] = {
+ if (wrappingKeyId != MasterKeyId) {
+ throw new RuntimeException(s"Unknown wrapping key id: $wrappingKeyId")
+ }
+ MasterKey
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]