From 7c27d9ae5e03b350c73822288ac06a82263f3426 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 1 Aug 2026 17:32:18 -0400 Subject: [PATCH 1/7] init --- .github/workflows/rust.yml | 47 + Cargo.lock | 2152 ++++++++++++++++- Cargo.toml | 13 + integrations/iceberg/Cargo.toml | 73 + integrations/iceberg/dev/docker-compose.yaml | 112 + .../examples/standalone-iceberg-write.rs | 163 ++ integrations/iceberg/src/bridge.rs | 352 +++ integrations/iceberg/src/lib.rs | 125 + integrations/iceberg/src/logical_codec.rs | 421 ++++ integrations/iceberg/src/physical_codec.rs | 675 ++++++ .../iceberg/tests/distributed_read_write.rs | 744 ++++++ 11 files changed, 4764 insertions(+), 113 deletions(-) create mode 100644 integrations/iceberg/Cargo.toml create mode 100644 integrations/iceberg/dev/docker-compose.yaml create mode 100644 integrations/iceberg/examples/standalone-iceberg-write.rs create mode 100644 integrations/iceberg/src/bridge.rs create mode 100644 integrations/iceberg/src/lib.rs create mode 100644 integrations/iceberg/src/logical_codec.rs create mode 100644 integrations/iceberg/src/physical_codec.rs create mode 100644 integrations/iceberg/tests/distributed_read_write.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ec5d7fa7c0..cc332ef48e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -29,6 +29,7 @@ on: - "ballista-cli/**" - "benchmarks/**" - "examples/**" + - "integrations/**" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -46,6 +47,7 @@ on: - "ballista-cli/**" - "benchmarks/**" - "examples/**" + - "integrations/**" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -155,6 +157,51 @@ jobs: # Ensure also compiles in standalone mode cargo test --profile ci --no-default-features --features standalone --locked + iceberg-test: + name: test iceberg integration + # Deliberately not in the amd64/rust container the other linux jobs use: + # this job drives docker compose to bring up an Iceberg REST catalog and + # MinIO, and the test talks to them over the ports they publish on the + # runner. Running in a container would mean docker-in-docker plus a + # different hostname for those endpoints. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + submodules: true + fetch-depth: 1 + - name: Install protobuf compiler + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + - name: Setup Rust toolchain + run: | + rustup toolchain install stable + rustup default stable + - name: Configure rust runtime env + uses: ./.github/actions/setup-rust-runtime + - uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #2.9.1 + - name: Start Iceberg REST catalog and MinIO + run: | + docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait + - name: Run iceberg-ballista tests + # The end-to-end suite is behind `integration-tests` precisely because it + # needs the fixture above; without the feature this only runs unit tests. + run: | + cargo test --profile ci -p iceberg-ballista --features integration-tests --locked + - name: Stop Iceberg REST catalog and MinIO + if: always() + run: | + docker compose -f integrations/iceberg/dev/docker-compose.yaml logs --no-color > /tmp/iceberg-fixture.log || true + docker compose -f integrations/iceberg/dev/docker-compose.yaml down -v --remove-orphans --timeout 0 + - name: Upload fixture logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: iceberg-fixture-logs + path: /tmp/iceberg-fixture.log + if-no-files-found: ignore + macos-test: name: macos test runs-on: macos-latest diff --git a/Cargo.lock b/Cargo.lock index 207c64c6a6..687ff1c347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -18,6 +53,7 @@ dependencies = [ "const-random", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -132,6 +168,33 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apache-avro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf" +dependencies = [ + "bigdecimal", + "bon", + "crc32fast", + "digest 0.10.7", + "log", + "miniz_oxide 0.8.9", + "num-bigint", + "quad-rand", + "rand 0.9.4", + "regex-lite", + "serde", + "serde_bytes", + "serde_json", + "snap", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.18", + "uuid", + "zstd", +] + [[package]] name = "approx" version = "0.5.1" @@ -159,6 +222,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arraydeque" version = "0.5.1" @@ -250,7 +319,7 @@ dependencies = [ "serde", "serde_json", "snap", - "strum_macros", + "strum_macros 0.28.0", "uuid", "zstd", ] @@ -453,6 +522,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "astral-tokio-tar" version = "0.6.2" @@ -469,6 +544,18 @@ dependencies = [ "xattr", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -481,6 +568,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -567,8 +665,8 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-schema", @@ -629,7 +727,7 @@ dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.64.0", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -645,6 +743,54 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-glue" +version = "1.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034b1d0fa165c64bf825b8ba5e5567b115f7439be1f13e51c5c754de98b896d9" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.3", + "aws-smithy-json 0.62.3", + "aws-smithy-observability 0.2.4", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-s3tables" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3939542b583da4db85c30868e32718633d6f7b0566d7cb978a9afd15545dcbd9" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.3", + "aws-smithy-json 0.62.3", + "aws-smithy-observability 0.2.4", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.105.0" @@ -655,9 +801,9 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-schema", @@ -681,9 +827,9 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-schema", @@ -707,9 +853,9 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", + "aws-smithy-observability 0.3.0", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -731,13 +877,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", - "aws-smithy-http", + "aws-smithy-http 0.64.0", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.13.0", "http 0.2.12", "http 1.5.0", "percent-encoding", @@ -757,6 +903,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "aws-smithy-http" +version = "0.63.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + [[package]] name = "aws-smithy-http" version = "0.64.0" @@ -802,6 +969,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-smithy-json" +version = "0.62.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +dependencies = [ + "aws-smithy-types", +] + [[package]] name = "aws-smithy-json" version = "0.63.0" @@ -813,6 +989,15 @@ dependencies = [ "aws-smithy-types", ] +[[package]] +name = "aws-smithy-observability" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +dependencies = [ + "aws-smithy-runtime-api", +] + [[package]] name = "aws-smithy-observability" version = "0.3.0" @@ -842,9 +1027,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.64.0", "aws-smithy-http-client", - "aws-smithy-observability", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime-api", "aws-smithy-schema", "aws-smithy-types", @@ -910,6 +1095,7 @@ dependencies = [ "base64-simd", "bytes", "bytes-utils", + "futures-core", "http 0.2.12", "http 1.5.0", "http-body 0.4.6", @@ -922,6 +1108,8 @@ dependencies = [ "ryu", "serde", "time", + "tokio", + "tokio-util", ] [[package]] @@ -1003,6 +1191,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers 0.3.0", + "tokio", +] + [[package]] name = "ballista" version = "54.0.0" @@ -1081,7 +1280,7 @@ dependencies = [ "dirs", "dotparser", "futures", - "gloo-timers", + "gloo-timers 0.4.0", "js-sys", "mimalloc", "percent-encoding", @@ -1257,6 +1456,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "beamterm-core" version = "1.0.0" @@ -1325,8 +1530,15 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", ] +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" + [[package]] name = "bit-set" version = "0.5.3" @@ -1410,6 +1622,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bnum" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f781dba93de3a5ef6dc5b17c9958b208f6f3f021623b360fb605ea51ce443f10" +dependencies = [ + "serde", + "serde-big-array", +] + [[package]] name = "bollard" version = "0.20.2" @@ -1484,6 +1706,31 @@ dependencies = [ "time", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "brotli" version = "8.0.3" @@ -1543,6 +1790,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "bytes-utils" @@ -1626,8 +1876,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -1668,6 +1920,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "2.34.0" @@ -1838,6 +2100,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1938,6 +2206,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -2016,6 +2293,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -2081,6 +2367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2134,6 +2421,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2143,14 +2439,38 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -2166,13 +2486,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -2992,6 +3323,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -3003,34 +3345,66 @@ dependencies = [ ] [[package]] -name = "derive_more" -version = "2.1.1" +name = "derive_builder" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "derive_more-impl", + "derive_builder_macro", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "derive_builder_core" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "convert_case", + "darling 0.20.11", "proc-macro2", "quote", - "rustc_version", "syn 2.0.117", ] [[package]] -name = "digest" -version = "0.10.7" +name = "derive_builder_macro" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "block-buffer 0.10.4", + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -3042,7 +3416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -3065,7 +3439,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3079,6 +3453,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dissimilar" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "docker_credential" version = "1.4.0" @@ -3114,6 +3503,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cfcded997a93eb31edd639361fa33fd229a8784e953b37d71035fe3890b7b" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dotparser" version = "0.3.0" @@ -3142,6 +3537,9 @@ name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] [[package]] name = "encode_unicode" @@ -3193,6 +3591,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -3200,7 +3609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3209,6 +3618,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -3228,6 +3648,36 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "expect-test" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63af43ff4431e848fb47472a920f14fa71c24de13255a5692e93d4e90302acb0" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -3244,12 +3694,36 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastnum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4089ab2dfd45d8ddc92febb5ca80644389d5ebb954f40231274a3f18341762e2" +dependencies = [ + "bnum", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "faststr" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca7d44d22004409a61c393afb3369c8f7bb74abcae49fe249ee01dcc3002113" +dependencies = [ + "bytes", + "rkyv", + "serde", + "simdutf8", +] + [[package]] name = "ferroid" version = "2.0.0" @@ -3327,6 +3801,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3408,6 +3893,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.33" @@ -3511,12 +4007,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-timers" version = "0.4.0" @@ -3606,6 +4124,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -3631,6 +4151,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hashlink" version = "0.11.1" @@ -3661,6 +4190,36 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hive_metastore" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a699b5fcbc7f4614c4fcd8154bedcaab8b741e524e5f1b7bf1e9b1dca9917d8a" +dependencies = [ + "anyhow", + "pilota", + "volo", + "volo-thrift", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -3844,7 +4403,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3891,6 +4450,215 @@ dependencies = [ "cc", ] +[[package]] +name = "iceberg" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "aes-gcm", + "anyhow", + "apache-avro", + "array-init", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", + "arrow-string", + "as-any", + "async-trait", + "backon", + "base64", + "bimap", + "bytes", + "chrono", + "derive_builder", + "expect-test", + "fastnum", + "flate2", + "fnv", + "futures", + "itertools 0.13.0", + "moka", + "murmur3", + "once_cell", + "ordered-float 4.6.0", + "parquet", + "rand 0.9.4", + "reqwest 0.12.28", + "roaring", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "serde_repr", + "serde_with", + "strum 0.27.2", + "tokio", + "tracing", + "typed-builder", + "typetag", + "url", + "uuid", + "zeroize", + "zstd", +] + +[[package]] +name = "iceberg-ballista" +version = "54.0.0" +dependencies = [ + "arrow", + "ballista", + "ballista-core", + "ballista-executor", + "ballista-scheduler", + "datafusion", + "datafusion-proto", + "env_logger", + "iceberg", + "iceberg-catalog-loader", + "iceberg-catalog-rest", + "iceberg-datafusion", + "iceberg-storage-opendal", + "log", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "iceberg-catalog-glue" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-sdk-glue", + "iceberg", + "iceberg-storage-opendal", + "serde_json", + "tokio", +] + +[[package]] +name = "iceberg-catalog-hms" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "faststr", + "hive_metastore", + "iceberg", + "linkedbytes", + "metainfo", + "motore-macros", + "pilota", + "serde_json", + "tokio", + "tracing", + "volo", + "volo-thrift", +] + +[[package]] +name = "iceberg-catalog-loader" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "async-trait", + "iceberg", + "iceberg-catalog-glue", + "iceberg-catalog-hms", + "iceberg-catalog-rest", + "iceberg-catalog-s3tables", + "iceberg-catalog-sql", + "tokio", +] + +[[package]] +name = "iceberg-catalog-rest" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "async-trait", + "chrono", + "http 1.5.0", + "iceberg", + "itertools 0.13.0", + "reqwest 0.12.28", + "serde", + "serde_derive", + "serde_json", + "tokio", + "typed-builder", + "uuid", +] + +[[package]] +name = "iceberg-catalog-s3tables" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-sdk-s3tables", + "iceberg", + "iceberg-storage-opendal", +] + +[[package]] +name = "iceberg-catalog-sql" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "async-trait", + "iceberg", + "sqlx", + "strum 0.27.2", +] + +[[package]] +name = "iceberg-datafusion" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "anyhow", + "async-trait", + "dashmap", + "datafusion", + "futures", + "iceberg", + "parquet", + "tokio", + "uuid", +] + +[[package]] +name = "iceberg-storage-opendal" +version = "0.10.0" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "cfg-if", + "futures", + "iceberg", + "opendal", + "reqsign-aws-v4", + "reqsign-core", + "serde", + "typetag", + "url", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -4038,6 +4806,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.48.0" @@ -4056,7 +4833,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -4069,6 +4846,16 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "integer-encoding" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c00403deb17c3221a1fe4fb571b9ed0370b3dcd116553c77fa294a3d918699" +dependencies = [ + "async-trait", + "tokio", +] + [[package]] name = "into-attr" version = "0.1.1" @@ -4091,6 +4878,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -4152,10 +4948,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", + "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -4169,6 +4969,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -4261,6 +5076,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -4378,7 +5196,20 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ + "bitflags 2.13.0", "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", ] [[package]] @@ -4396,6 +5227,23 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linkedbytes" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80dd357febf29a3b1e37d0ff3509b035077769e5f4af161ff32edc3e97a78548" +dependencies = [ + "bytes", + "faststr", + "tokio", +] + [[package]] name = "linktime-proc-macro" version = "0.2.2" @@ -4519,6 +5367,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "mea" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31fc7d159de0085ab6dd7ff145a9819442cfd3d098f783263120503c3f3e58b0" +dependencies = [ + "slab", +] + [[package]] name = "memchr" version = "2.8.2" @@ -4550,63 +5407,151 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "metainfo" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc67a1d6ef0340a7f5152b9fd34ad7477b4d518920f7557267f1fc6e5a62641" +dependencies = [ + "ahash", + "faststr", + "paste", + "rustc-hash", + "tokio", +] + [[package]] name = "mimalloc" version = "0.1.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" dependencies = [ - "libmimalloc-sys", + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "motore" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "983b283a2be61606ca0c16b1a8ab2110b2eb0d7284293658d510e91bd705508a" +dependencies = [ + "futures", + "motore-macros", + "pin-project", + "tokio", ] [[package]] -name = "mime" -version = "0.3.17" +name = "motore-macros" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "b40e46c845ac234bcba19db7ab252bc2778cbadd516a466d2f12b1580852d136" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "multimap" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "munge" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" dependencies = [ - "adler2", - "simd-adler32", + "munge_macro", ] [[package]] -name = "miniz_oxide" -version = "0.9.1" +name = "munge_macro" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ - "adler2", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "mio" -version = "1.2.1" +name = "mur3" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] +checksum = "97af489e1e21b68de4c390ecca6703318bc1aa16e9733bcb62c089b73c6fbb1b" [[package]] -name = "multimap" -version = "0.10.1" +name = "murmur3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" [[package]] name = "nibble_vec" @@ -4667,7 +5612,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4692,6 +5637,23 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", ] [[package]] @@ -4761,6 +5723,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -4826,7 +5810,7 @@ dependencies = [ "md-5 0.10.6", "parking_lot", "percent-encoding", - "quick-xml", + "quick-xml 0.39.4", "rand 0.10.2", "reqwest 0.12.28", "ring", @@ -4861,6 +5845,134 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opendal" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c9c85ce253ff87225e7669979d877a20c98a06604ec9d6dd5f4473e08f1ae1" +dependencies = [ + "ctor", + "opendal-core", + "opendal-layer-concurrent-limit", + "opendal-layer-logging", + "opendal-layer-retry", + "opendal-layer-timeout", + "opendal-service-fs", + "opendal-service-s3", +] + +[[package]] +name = "opendal-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4f8607c90e2c963a91467f50fb49fbc7fb3d573f88cea219ca59ccd3740b309" +dependencies = [ + "anyhow", + "base64", + "bytes", + "futures", + "http 1.5.0", + "http-body 1.0.1", + "jiff", + "log", + "md-5 0.11.0", + "mea", + "percent-encoding", + "quick-xml 0.39.4", + "reqsign-core", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "opendal-layer-concurrent-limit" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6f81ba6960e3fae1882f253b114b21d7e444e1534f209c7737a79f6243eb6f" +dependencies = [ + "futures", + "http 1.5.0", + "mea", + "opendal-core", +] + +[[package]] +name = "opendal-layer-logging" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ada45c6d81d1aa4c9305d0c7d4bc317c59c85866a0908a2d75a7a978aa5ee2" +dependencies = [ + "log", + "opendal-core", +] + +[[package]] +name = "opendal-layer-retry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2a25a718afb81fad81cb9a0580a1cb989221fa2317f888c6a37f8dad408eb7" +dependencies = [ + "backon", + "log", + "opendal-core", +] + +[[package]] +name = "opendal-layer-timeout" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e91f731724c213af81e9d03517859c8fc47b4578e64ad61ae4f099f10fe36e3" +dependencies = [ + "opendal-core", + "tokio", +] + +[[package]] +name = "opendal-service-fs" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e89a665fef0e6bd249cf5ea47fc174b7ba892159bee4b9382528b1ca873a2c" +dependencies = [ + "bytes", + "log", + "opendal-core", + "serde", + "tokio", + "xattr", +] + +[[package]] +name = "opendal-service-s3" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313d46c9f5ae70bca26b7c3e3fbb9b639292625f28af73aa016f47e788af9deb" +dependencies = [ + "base64", + "bytes", + "crc32c", + "http 1.5.0", + "log", + "md-5 0.11.0", + "opendal-core", + "quick-xml 0.39.4", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4889,6 +6001,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ "num-traits", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", ] [[package]] @@ -4931,6 +6055,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -4949,16 +6079,16 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] [[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", @@ -5065,6 +6195,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5196,6 +6335,28 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pilota" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520e32b295867834617b8a352b6fd4837e578f328f4e48813db4904ebc0d70b2" +dependencies = [ + "ahash", + "anyhow", + "async-recursion", + "bytes", + "faststr", + "integer-encoding 4.1.0", + "lazy_static", + "linkedbytes", + "ordered-float 4.6.0", + "paste", + "serde", + "smallvec", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -5228,12 +6389,39 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -5262,6 +6450,18 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5421,7 +6621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", - "itertools 0.14.0", + "itertools 0.12.1", "log", "multimap", "petgraph", @@ -5442,7 +6642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", @@ -5487,6 +6687,26 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -5507,6 +6727,12 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "quad-rand" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" + [[package]] name = "quick-xml" version = "0.39.4" @@ -5517,6 +6743,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5530,7 +6766,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5569,7 +6805,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.59.0", ] @@ -5607,8 +6843,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" dependencies = [ - "endian-type", - "nibble_vec", + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rancor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daff8b7b3ccf5f7ba270b3e7a0a4d4c701c5797e38dec27c7e2c3dbb830fed1c" +dependencies = [ + "ptr_meta", ] [[package]] @@ -5617,7 +6862,10 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", + "serde", ] [[package]] @@ -5626,7 +6874,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -5641,6 +6889,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -5656,6 +6914,10 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", + "serde", +] [[package]] name = "rand_core" @@ -5713,7 +6975,7 @@ dependencies = [ "lru 0.18.1", "palette", "serde", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -5789,7 +7051,7 @@ dependencies = [ "line-clipping", "ratatui-core", "serde", - "strum", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -5860,6 +7122,15 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5942,6 +7213,66 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" + +[[package]] +name = "reqsign-aws-v4" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc883bc56889f3e4a419265c87facea222a921debc5c6f15c7fd8b68ec4b36b2" +dependencies = [ + "anyhow", + "bytes", + "form_urlencoded", + "hex", + "http 1.5.0", + "log", + "percent-encoding", + "quick-xml 0.41.0", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", + "serde_urlencoded", + "sha1 0.11.0", +] + +[[package]] +name = "reqsign-core" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e38b44697c60a823705ccef85cb04d8e0527c9d16ed7c58bf1c6395bdd24ceb" +dependencies = [ + "anyhow", + "base64", + "bytes", + "futures", + "hex", + "hmac 0.13.0", + "http 1.5.0", + "jiff", + "log", + "percent-encoding", + "sha1 0.11.0", + "sha2 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688ff0ae421b8d4b92b53fdafaf53df2de28f428a9962edcf21702990b26f74b" +dependencies = [ + "anyhow", + "reqsign-core", + "tokio", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -5980,7 +7311,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", ] @@ -5994,6 +7325,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http 1.5.0", "http-body 1.0.1", @@ -6015,12 +7347,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -6038,6 +7372,65 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +dependencies = [ + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "roaring" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstest" version = "0.26.1" @@ -6067,11 +7460,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +dependencies = [ + "rand 0.8.6", +] [[package]] name = "rustc-std-workspace-core" @@ -6111,7 +7517,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6170,7 +7576,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6345,6 +7751,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -6462,7 +7887,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6571,6 +7996,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -6625,6 +8060,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "snap" @@ -6632,6 +8070,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -6643,25 +8091,227 @@ dependencies = [ ] [[package]] -name = "sqlparser" -version = "0.62.0" +name = "sonic-number" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3775c3390edf958191f1ab1e8c5c188907feebd0f3ce1604cb621f72961dbf32" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "sonic-rs" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0275f9f2f07d47556fe60c2759da8bc4be6083b047b491b2d476aa0bfa558eb1" +dependencies = [ + "bumpalo", + "bytes", + "cfg-if", + "faststr", + "itoa", + "ref-cast", + "ryu", + "serde", + "simdutf8", + "sonic-number", + "sonic-simd", + "thiserror 2.0.18", +] + +[[package]] +name = "sonic-simd" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f99e664ecd2d85a68c87e3c7a3cfe691f647ea9e835de984aba4d54a41f817d4" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "sha1 0.10.6", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", "log", - "recursive", - "sqlparser_derive", + "md-5 0.10.6", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", ] [[package]] -name = "sqlparser_derive" -version = "0.5.0" +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", ] [[package]] @@ -6689,6 +8339,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -6742,13 +8403,34 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -6889,6 +8571,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -6905,7 +8593,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7008,7 +8696,7 @@ dependencies = [ "bytes", "docker_credential", "either", - "etcetera", + "etcetera 0.11.0", "ferroid", "futures", "http 1.5.0", @@ -7101,7 +8789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", - "integer-encoding", + "integer-encoding 3.0.4", "ordered-float 2.10.1", ] @@ -7194,7 +8882,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -7295,7 +8983,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2", + "socket2 0.6.4", "sync_wrapper", "tokio", "tokio-rustls", @@ -7522,12 +9210,62 @@ dependencies = [ "rand 0.9.4", ] +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typetag" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.1", +] + [[package]] name = "typify" version = "0.5.0" @@ -7587,12 +9325,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -7631,6 +9390,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7726,12 +9495,74 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "volo" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7cb968b0ee56542a3571eb84180d5433ba0f838a6be97c5505cf17c80f7448" +dependencies = [ + "async-broadcast", + "dashmap", + "faststr", + "futures", + "libc", + "metainfo", + "motore", + "mur3", + "nix 0.29.0", + "once_cell", + "pin-project", + "rand 0.9.4", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "volo-thrift" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a41a936651c3b8206339898466fd4f39596cfedf789e5ee6a8d02a45feb45f" +dependencies = [ + "ahash", + "anyhow", + "bytes", + "chrono", + "futures", + "itoa", + "linked-hash-map", + "linkedbytes", + "metainfo", + "motore", + "num_enum", + "parking_lot", + "paste", + "pilota", + "pin-project", + "rustc-hash", + "scopeguard", + "sonic-rs", + "thiserror 2.0.18", + "tokio", + "tracing", + "volo", +] + [[package]] name = "vsimd" version = "0.8.0" @@ -7790,6 +9621,12 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.123" @@ -7880,6 +9717,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -7995,6 +9845,16 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8017,7 +9877,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8138,13 +9998,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -8153,7 +10022,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -8165,20 +10034,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -8190,18 +10074,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8214,24 +10116,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8380,7 +10306,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.11.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b29fc92dc4..3e7745b6fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "benchmarks", "chaos-testing", "examples", + "integrations/iceberg", ] resolver = "3" @@ -51,6 +52,18 @@ datafusion-proto-common = "54" datafusion-spark = "54" datafusion-substrait = "54" +# iceberg-ballista (integrations/iceberg) needs iceberg-rust changes that are not +# on crates.io yet: the catalog-config plumbing in `iceberg-datafusion` that lets +# scan/write plan nodes be rebuilt on a remote executor. Until those land +# upstream and are released, all five crates are pinned to the branch that +# carries them — keep the git/branch identical across every entry so cargo +# resolves them to a single checkout. +iceberg = { git = "https://github.com/NoahKusaba/iceberg-rust.git", branch = "feature/datafusion-ballista" } +iceberg-catalog-loader = { git = "https://github.com/NoahKusaba/iceberg-rust.git", branch = "feature/datafusion-ballista" } +iceberg-catalog-rest = { git = "https://github.com/NoahKusaba/iceberg-rust.git", branch = "feature/datafusion-ballista" } +iceberg-datafusion = { git = "https://github.com/NoahKusaba/iceberg-rust.git", branch = "feature/datafusion-ballista" } +iceberg-storage-opendal = { git = "https://github.com/NoahKusaba/iceberg-rust.git", branch = "feature/datafusion-ballista" } + ctor = { version = "1.0" } insta = "1.47" itertools = "0.15" diff --git a/integrations/iceberg/Cargo.toml b/integrations/iceberg/Cargo.toml new file mode 100644 index 0000000000..6b2ae82c00 --- /dev/null +++ b/integrations/iceberg/Cargo.toml @@ -0,0 +1,73 @@ +# 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] +name = "iceberg-ballista" +description = "Apache Iceberg distributed read/write driver for Ballista" +license = "Apache-2.0" +version = "54.0.0" +homepage = "https://datafusion.apache.org/ballista/" +repository = "https://github.com/apache/datafusion-ballista" +authors = ["Apache DataFusion "] +keywords = ["iceberg", "ballista", "datafusion", "distributed"] +categories = ["database"] +edition = { workspace = true } +# Higher than the workspace MSRV on purpose: the iceberg crates require 1.94. +# Only this crate is affected — the rest of the workspace still builds on the +# workspace-wide rust-version. +rust-version = "1.94" +# The iceberg crates are consumed as git dependencies while the supporting +# changes live on a branch, and crates.io rejects git dependencies. Flip this +# back on once those crates are released and the deps below become versioned. +publish = false + +[dependencies] +ballista-core = { path = "../../ballista/core", version = "54.0.0" } +datafusion = { workspace = true } +datafusion-proto = { workspace = true } +iceberg = { workspace = true } +iceberg-catalog-loader = { workspace = true } +iceberg-datafusion = { workspace = true } +iceberg-storage-opendal = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = "1" +tokio = { workspace = true } + +[dev-dependencies] +arrow = { workspace = true } +ballista = { path = "../../ballista/client", version = "54.0.0", features = [ + "standalone", +] } +# Used by the multi-executor integration test to start a scheduler + several +# in-process executors directly. Feature flags mirror what ballista's +# `standalone` feature already enables, so these reuse the same compiled +# artifacts rather than triggering a separate build. +ballista-executor = { path = "../../ballista/executor", version = "54.0.0", default-features = false, features = [ + "arrow-ipc-optimizations", +] } +ballista-scheduler = { path = "../../ballista/scheduler", version = "54.0.0", default-features = false } +env_logger = { workspace = true } +iceberg-catalog-rest = { workspace = true } +log = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[features] +default = [] +# The end-to-end suite needs the Iceberg REST catalog + MinIO fixture in +# `dev/docker-compose.yaml`, so it is off by default and opted into explicitly: +# cargo test -p iceberg-ballista --features integration-tests +integration-tests = [] diff --git a/integrations/iceberg/dev/docker-compose.yaml b/integrations/iceberg/dev/docker-compose.yaml new file mode 100644 index 0000000000..e1f62d548a --- /dev/null +++ b/integrations/iceberg/dev/docker-compose.yaml @@ -0,0 +1,112 @@ +# 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. + +# Fixture for the iceberg-ballista integration tests and the +# standalone-iceberg-write example: an Iceberg REST catalog backed by MinIO. +# Trimmed down from the iceberg-rust dev fixture — only the REST catalog is +# exercised here, so the HMS / Glue / GCS / Spark services are omitted. +# +# docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait +# cargo test -p iceberg-ballista --features integration-tests +# docker compose -f integrations/iceberg/dev/docker-compose.yaml down -v +# +# Note: `docker compose restart rest` on its own drops the REST -> MinIO network +# alias and later table creations fail with an unknown-host error. Recreate the +# whole set instead: `... up -d --force-recreate --wait`. + +networks: + iceberg_test: + +services: + # S3-compatible storage for table data and metadata. + minio: + image: minio/minio:RELEASE.2025-05-24T17-08-30Z + environment: + - MINIO_ROOT_USER=admin + - MINIO_ROOT_PASSWORD=password + - MINIO_DOMAIN=minio + hostname: minio + networks: + iceberg_test: + # Alias for virtual-hosted style bucket access. + aliases: + - icebergdata.minio + ports: + - "9000:9000" + - "9001:9001" + command: ["server", "/data", "--console-address", ":9001"] + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + # Creates the warehouse bucket the REST catalog writes to. + mc: + depends_on: + minio: + condition: service_healthy + image: minio/mc:RELEASE.2025-05-21T01-59-54Z + environment: + - AWS_ACCESS_KEY_ID=admin + - AWS_SECRET_ACCESS_KEY=password + - AWS_REGION=us-east-1 + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set minio http://minio:9000 admin password; + /usr/bin/mc mb --ignore-existing minio/icebergdata; + /usr/bin/mc policy set public minio/icebergdata; + touch /tmp/buckets_ready; + echo 'Buckets created successfully'; + tail -f /dev/null + " + networks: + iceberg_test: + # Without this, `up --wait` returns as soon as the container is *running* — + # which is before `mc mb` has finished — and the first create-table races + # the bucket into existence. + healthcheck: + test: ["CMD-SHELL", "[ -f /tmp/buckets_ready ]"] + interval: 2s + timeout: 2s + retries: 30 + + # Iceberg REST catalog (JDBC catalog over in-memory SQLite). + rest: + image: apache/iceberg-rest-fixture:1.10.1 + environment: + - AWS_ACCESS_KEY_ID=admin + - AWS_SECRET_ACCESS_KEY=password + - AWS_REGION=us-east-1 + - CATALOG_CATALOG__IMPL=org.apache.iceberg.jdbc.JdbcCatalog + - CATALOG_URI=jdbc:sqlite:file:/tmp/iceberg_rest_mode=memory + - CATALOG_WAREHOUSE=s3://icebergdata/demo + - CATALOG_IO__IMPL=org.apache.iceberg.aws.s3.S3FileIO + - CATALOG_S3_ENDPOINT=http://minio:9000 + depends_on: + minio: + condition: service_healthy + networks: + iceberg_test: + ports: + - "8181:8181" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8181/v1/config"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s diff --git a/integrations/iceberg/examples/standalone-iceberg-write.rs b/integrations/iceberg/examples/standalone-iceberg-write.rs new file mode 100644 index 0000000000..13b355f7bd --- /dev/null +++ b/integrations/iceberg/examples/standalone-iceberg-write.rs @@ -0,0 +1,163 @@ +// 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. + +//! # Standalone Iceberg write example +//! +//! Demonstrates distributed reads and writes against an Apache Iceberg table +//! from a standalone Ballista cluster. +//! +//! This example requires a running Iceberg REST catalog and MinIO. Bring up the +//! docker fixture from the workspace root: +//! +//! ```bash +//! docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait +//! cargo run -p iceberg-ballista --example standalone-iceberg-write +//! ``` +//! +//! Endpoints can be overridden with the `ICEBERG_REST_URI` and +//! `ICEBERG_S3_ENDPOINT` environment variables. + +use std::collections::HashMap; +use std::sync::Arc; + +use ballista::datafusion::common::Result; +use ballista::datafusion::execution::SessionStateBuilder; +use ballista::datafusion::prelude::{SessionConfig, SessionContext}; +use ballista::prelude::{SessionConfigExt, SessionContextExt}; +use iceberg::spec::{NestedField, PrimitiveType, Schema, Type}; +use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, TableCreation, TableIdent}; +use iceberg_ballista::{ + IcebergCatalogConfig, register_iceberg_codecs, register_iceberg_table, +}; +use iceberg_catalog_rest::RestCatalogBuilder; +use iceberg_storage_opendal::OpenDalStorageFactory; + +/// Catalog + storage properties for the REST catalog and its MinIO storage. +fn catalog_props() -> HashMap { + let rest_uri = std::env::var("ICEBERG_REST_URI") + .unwrap_or_else(|_| "http://localhost:8181".to_string()); + let s3_endpoint = std::env::var("ICEBERG_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_string()); + HashMap::from([ + ("uri".to_string(), rest_uri), + ("s3.endpoint".to_string(), s3_endpoint), + ("s3.access-key-id".to_string(), "admin".to_string()), + ("s3.secret-access-key".to_string(), "password".to_string()), + ("s3.region".to_string(), "us-east-1".to_string()), + ("s3.path-style-access".to_string(), "true".to_string()), + ]) +} + +/// Creates the demo namespace and table in the catalog if they do not exist. +async fn ensure_table( + props: &HashMap, +) -> Result<(NamespaceIdent, String)> { + let catalog = RestCatalogBuilder::default() + .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 { + customized_credential_load: None, + })) + .load("rest", props.clone()) + .await + .expect("build rest catalog"); + + let namespace = NamespaceIdent::new("ballista_demo".to_string()); + if !catalog + .namespace_exists(&namespace) + .await + .expect("ns exists") + { + catalog + .create_namespace(&namespace, HashMap::new()) + .await + .expect("create namespace"); + } + + let table_ident = TableIdent::new(namespace.clone(), "events".to_string()); + if !catalog + .table_exists(&table_ident) + .await + .expect("table exists") + { + let schema = Schema::builder() + .with_schema_id(0) + .with_fields(vec![ + NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)) + .into(), + NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)) + .into(), + ]) + .build() + .expect("build schema"); + let creation = TableCreation::builder() + .name("events".to_string()) + .schema(schema) + .properties(HashMap::new()) + .build(); + catalog + .create_table(&namespace, creation) + .await + .expect("create table"); + } + + Ok((namespace, "events".to_string())) +} + +#[tokio::main] +async fn main() -> Result<()> { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .try_init(); + + let props = catalog_props(); + + // Make sure the target table exists in the catalog. + let (namespace, table) = ensure_table(&props).await?; + + // Build a Ballista session config with the Iceberg codecs installed, so the + // standalone scheduler and executor can serialize the Iceberg plan nodes. + let config = register_iceberg_codecs( + SessionConfig::new_with_ballista() + .with_target_partitions(2) + .with_ballista_standalone_parallelism(2), + ); + let state = SessionStateBuilder::new() + .with_config(config) + .with_default_features() + .build(); + let ctx = SessionContext::standalone_with_state(state).await?; + + // Register the catalog-backed Iceberg table for distributed reads and writes. + let catalog_config = IcebergCatalogConfig::new("rest", "rest", props); + register_iceberg_table(&ctx, "events", catalog_config, namespace, table).await?; + + // Distributed INSERT: IcebergWriteExec runs across the cluster and + // IcebergCommitExec atomically appends the data files to the table. + println!("== INSERT =="); + ctx.sql("INSERT INTO events VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')") + .await? + .show() + .await?; + + // Read it back through the distributed scan. + println!("== SELECT =="); + ctx.sql("SELECT id, name FROM events ORDER BY id") + .await? + .show() + .await?; + + Ok(()) +} diff --git a/integrations/iceberg/src/bridge.rs b/integrations/iceberg/src/bridge.rs new file mode 100644 index 0000000000..3c8bf60a12 --- /dev/null +++ b/integrations/iceberg/src/bridge.rs @@ -0,0 +1,352 @@ +// 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. + +//! Bridge between Ballista's synchronous, serialization-oriented codec API and +//! Iceberg's asynchronous, live-handle world, shared by the logical and +//! physical extension codecs. +//! +//! The central problem this module solves is that Ballista serializes physical +//! and logical plans to ship them to remote nodes, but the Iceberg plan nodes +//! hold live, non-serializable state (an `Arc` and a `Table` with +//! an open `FileIO`). We side-step this by serializing only the minimal, +//! self-contained [`IcebergCatalogConfig`] plus the [`TableIdent`], and then +//! *reconstructing* the catalog and table on the receiving node by building the +//! catalog from the config and loading the table from it. +//! +//! Reconstruction is asynchronous (catalog clients and table loads do I/O) but +//! the codec entry points are synchronous, so [`block_on`] bridges the two by +//! running every catalog future on [`CATALOG_RT`], a dedicated process-lived +//! runtime. + +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::{Arc, LazyLock, Mutex}; + +use datafusion::common::DataFusionError; +use iceberg::table::Table; +use iceberg::{Catalog, Error, ErrorKind, TableIdent}; +use iceberg_datafusion::{IcebergCatalogConfig, to_datafusion_error}; +use iceberg_storage_opendal::OpenDalResolvingStorageFactory; +use serde::{Deserialize, Serialize}; + +/// Converts a non-Iceberg error (serde, etc.) into a [`DataFusionError`]. +/// Iceberg errors use [`to_datafusion_error`], the workspace's conversion. +pub(crate) fn to_df_err( + e: E, +) -> DataFusionError { + DataFusionError::External(Box::new(e)) +} + +/// Dedicated process-lived runtime that drives all Iceberg catalog I/O. +/// +/// A catalog's HTTP/connection pool is bound to the runtime that drives it, so +/// running every catalog future here — instead of on whatever runtime the codec +/// caller happens to be on — means a cached catalog can never reference an +/// already-dropped runtime, no matter which thread or test asks for it later. +/// Catalog operations only happen at plan encode/decode time, so one worker is +/// plenty. +static CATALOG_RT: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("iceberg-catalog") + .enable_all() + .build() + .expect("failed to build iceberg catalog runtime") +}); + +/// Runs an async future to completion on [`CATALOG_RT`] from a synchronous +/// context, whatever runtime (if any) the caller happens to be on. +/// +/// The future runs on a scoped helper thread (entering another runtime's +/// `block_on` is forbidden from inside a runtime context, and the helper thread +/// also lets `fut` borrow from the caller). If the caller is itself on a +/// multi-thread runtime worker, [`tokio::task::block_in_place`] tells that +/// scheduler the worker is parked so the rest of its runtime keeps making +/// progress. +pub(crate) fn block_on(fut: F) -> F::Output +where + F: Future + Send, + F::Output: Send, +{ + use tokio::runtime::{Handle, RuntimeFlavor}; + + let wait = move || { + std::thread::scope(|scope| { + scope + .spawn(|| CATALOG_RT.block_on(fut)) + .join() + .expect("iceberg catalog access thread panicked") + }) + }; + + match Handle::try_current().map(|h| h.runtime_flavor()) { + Ok(RuntimeFlavor::MultiThread) => tokio::task::block_in_place(wait), + _ => wait(), + } +} + +/// Process-wide cache of reconstructed catalogs, keyed by config. +/// +/// Building a catalog client (and its underlying HTTP/connection pool) is +/// relatively expensive, and the codec may decode many plan nodes that share +/// one catalog, so we cache by config. Every cached catalog lives on +/// [`CATALOG_RT`], which never shuts down, so entries stay valid for the life +/// of the process and can be served to any caller. +static CATALOGS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Builds a catalog from its config. +/// +/// The catalog type is resolved through [`iceberg_catalog_loader`], so any +/// catalog it supports (`rest`, `sql`, `glue`, `hms`, `s3tables`) works here. +/// Storage is provided by [`OpenDalResolvingStorageFactory`], which picks the +/// object-store backend (S3, GCS, Azure, local fs, …) from each file's path +/// scheme, configured from the same `props`. So a single code path covers every +/// catalog/storage combination the workspace supports. +pub(crate) async fn build_catalog( + config: &IcebergCatalogConfig, +) -> Result, DataFusionError> { + iceberg_catalog_loader::load(&config.r#type) + .map_err(to_datafusion_error)? + .with_storage_factory(Arc::new(OpenDalResolvingStorageFactory::new())) + .load(config.name.clone(), config.props.clone()) + .await + .map_err(to_datafusion_error) +} + +/// Returns a catalog built from `config`, cached process-wide. +pub(crate) fn get_catalog( + config: &IcebergCatalogConfig, +) -> Result, DataFusionError> { + let key = CatalogConfigWire::from(config); + if let Some(catalog) = CATALOGS.lock().unwrap().get(&key) { + return Ok(catalog.clone()); + } + let catalog = block_on(build_catalog(config))?; + CATALOGS.lock().unwrap().insert(key, catalog.clone()); + Ok(catalog) +} + +/// Drops any cached catalog for `config`, so the next [`get_catalog`] rebuilds +/// it — reopening connections and re-resolving credentials. +fn evict_catalog(config: &IcebergCatalogConfig) { + CATALOGS + .lock() + .unwrap() + .remove(&CatalogConfigWire::from(config)); +} + +/// Whether a catalog error is worth one rebuild-and-retry. +/// +/// Iceberg has no dedicated auth/transport [`ErrorKind`] — the REST client maps +/// every HTTP failure, including the 401/403 of an expired token, to +/// [`ErrorKind::Unexpected`]. So only that catch-all can hide a stale-client +/// failure a rebuild would fix; the semantic kinds are deterministic and would +/// fail identically. +fn is_retryable(err: &Error) -> bool { + matches!(err.kind(), ErrorKind::Unexpected) +} + +/// Loads a fresh [`Table`] from the catalog described by `config`, returning +/// the catalog that served the successful load alongside it. +/// +/// A retryable failure (see [`is_retryable`]) evicts the cached catalog and +/// retries once against a rebuilt one, so an executor whose credentials went +/// stale recovers instead of failing every decode until restart. A second +/// failure propagates. +/// +/// Returning the catalog matters for that retry: a caller that needs both (the +/// commit node holds a catalog handle) must get the *rebuilt* catalog, not the +/// stale one a separate [`get_catalog`] call before the eviction would have +/// returned. Callers that only want the table ignore it: `let (_, table) = …`. +pub(crate) fn load_table( + config: &IcebergCatalogConfig, + ident: &TableIdent, +) -> Result<(Arc, Table), DataFusionError> { + let catalog = get_catalog(config)?; + match block_on(catalog.load_table(ident)) { + Ok(table) => Ok((catalog, table)), + Err(e) if is_retryable(&e) => { + evict_catalog(config); + let fresh = get_catalog(config)?; + let table = block_on(fresh.load_table(ident)).map_err(to_datafusion_error)?; + Ok((fresh, table)) + } + Err(e) => Err(to_datafusion_error(e)), + } +} + +// --------------------------------------------------------------------------- +// Wire format +// --------------------------------------------------------------------------- + +/// Leading tag byte that frames every blob this crate's codecs produce. +/// +/// Both the logical and physical codecs handle some nodes themselves and +/// delegate the rest to an inner Ballista codec. We write a tag for *both* branches. Decode +/// then dispatches on a value we always control, and the inner payload is nested +/// after the tag — never content-inspected. An unknown or missing tag is a hard +/// error instead of a silent misparse. +pub(crate) const TAG_DELEGATED: u8 = 0; +/// Tag for a payload owned by this crate's Iceberg codecs (JSON follows). +pub(crate) const TAG_ICEBERG: u8 = 1; + +/// Frames `payload` as an Iceberg-owned blob: [`TAG_ICEBERG`] then JSON. +pub(crate) fn encode_blob( + buf: &mut Vec, + payload: &T, +) -> Result<(), DataFusionError> { + buf.push(TAG_ICEBERG); + buf.extend_from_slice(&serde_json::to_vec(payload).map_err(to_df_err)?); + Ok(()) +} + +/// A codec blob split into its framing tag and payload. +pub(crate) enum Frame<'a> { + /// Payload owned by the inner (delegate) codec. + Delegated(&'a [u8]), + /// Payload owned by this crate's Iceberg codecs (JSON). + Iceberg(&'a [u8]), +} + +/// Splits a codec blob into its [`Frame`], so which tags are legal is decided +/// here — next to the tags — rather than at every decode site. `context` names +/// the blob kind in errors. +pub(crate) fn split_frame<'a>( + buf: &'a [u8], + context: &str, +) -> Result, DataFusionError> { + match buf.split_first() { + Some((&TAG_DELEGATED, rest)) => Ok(Frame::Delegated(rest)), + Some((&TAG_ICEBERG, rest)) => Ok(Frame::Iceberg(rest)), + Some((&tag, _)) => Err(DataFusionError::Internal(format!( + "unknown {context} tag {tag}" + ))), + None => Err(DataFusionError::Internal(format!("empty {context} buffer"))), + } +} + +/// Serializable mirror of [`IcebergCatalogConfig`] (which is intentionally not +/// serde-aware in the iceberg crate to avoid a serde dependency there). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub(crate) struct CatalogConfigWire { + pub r#type: String, + pub name: String, + // BTreeMap (not HashMap) so the struct can derive Hash for the catalog + // cache, and so encode→decode→encode round-trips to identical bytes. + pub props: BTreeMap, +} + +impl From<&IcebergCatalogConfig> for CatalogConfigWire { + fn from(c: &IcebergCatalogConfig) -> Self { + Self { + r#type: c.r#type.clone(), + name: c.name.clone(), + props: c + .props + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } + } +} + +impl From for IcebergCatalogConfig { + fn from(p: CatalogConfigWire) -> Self { + IcebergCatalogConfig::new(p.r#type, p.name, p.props.into_iter().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_props() -> [(String, String); 3] { + [ + ("uri".to_string(), "http://localhost:8181".to_string()), + ("warehouse".to_string(), "s3://bucket/wh".to_string()), + ("s3.region".to_string(), "us-east-1".to_string()), + ] + } + + #[test] + fn only_the_catch_all_error_kind_is_retryable() { + assert!(is_retryable(&Error::new( + ErrorKind::Unexpected, + "http 401 unauthorized" + ))); + + for kind in [ + ErrorKind::PreconditionFailed, + ErrorKind::DataInvalid, + ErrorKind::NamespaceAlreadyExists, + ErrorKind::TableAlreadyExists, + ErrorKind::NamespaceNotFound, + ErrorKind::TableNotFound, + ErrorKind::FeatureUnsupported, + ErrorKind::CatalogCommitConflicts, + ] { + assert!( + !is_retryable(&Error::new(kind, "deterministic")), + "{kind:?} must not trigger a rebuild" + ); + } + } + + #[test] + fn evicting_an_uncached_config_is_a_noop() { + // The retry path evicts unconditionally, so an uncached config must not + // panic and poison the cache for every later decode. + let config = IcebergCatalogConfig::new( + "rest", + "never-cached", + sample_props().into_iter().collect(), + ); + evict_catalog(&config); + evict_catalog(&config); + } + + #[test] + fn catalog_config_wire_roundtrips_to_identical_config() { + let config = IcebergCatalogConfig::new( + "rest", + "rest", + sample_props().into_iter().collect(), + ); + let restored: IcebergCatalogConfig = CatalogConfigWire::from(&config).into(); + assert_eq!(restored, config); + } + + #[test] + fn catalog_config_wire_serialization_is_deterministic() { + // BTreeMap props serialize in key order, so the same entries yield the same + // bytes regardless of HashMap ordering — what the cache key relies on. + let forward: HashMap<_, _> = sample_props().into_iter().collect(); + let reversed: HashMap<_, _> = sample_props().into_iter().rev().collect(); + + let a = + CatalogConfigWire::from(&IcebergCatalogConfig::new("rest", "rest", forward)); + let b = + CatalogConfigWire::from(&IcebergCatalogConfig::new("rest", "rest", reversed)); + let a_bytes = serde_json::to_vec(&a).unwrap(); + assert_eq!(a_bytes, serde_json::to_vec(&b).unwrap()); + + // encode→decode→encode is a fixed point. + let decoded: CatalogConfigWire = serde_json::from_slice(&a_bytes).unwrap(); + assert_eq!(serde_json::to_vec(&decoded).unwrap(), a_bytes); + } +} diff --git a/integrations/iceberg/src/lib.rs b/integrations/iceberg/src/lib.rs new file mode 100644 index 0000000000..9099d75e70 --- /dev/null +++ b/integrations/iceberg/src/lib.rs @@ -0,0 +1,125 @@ +// 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. + +//! # Ballista Iceberg driver +//! +//! Adds distributed Apache Iceberg reads and writes to Ballista. +//! +//! Iceberg's DataFusion integration already produces a complete physical plan; +//! the only thing missing for Ballista is serialization. Ballista ships plans to +//! remote nodes, but the Iceberg plan nodes hold live catalog/storage handles +//! that can't be serialized. This crate's logical and physical extension codecs +//! serialize the minimal config needed to rebuild those handles per node. +//! +//! ## Usage (standalone) +//! +//! ```ignore +//! use std::collections::HashMap; +//! use std::sync::Arc; +//! +//! use iceberg_ballista::{register_iceberg_codecs, register_iceberg_table, IcebergCatalogConfig}; +//! use ballista_core::extension::SessionConfigExt; +//! use datafusion::prelude::{SessionConfig, SessionContext}; +//! use iceberg::NamespaceIdent; +//! +//! # async fn run() -> datafusion::error::Result<()> { +//! // 1. Register the Iceberg codecs on the session config, then start standalone Ballista. +//! let config = register_iceberg_codecs(SessionConfig::new_with_ballista()); +//! let ctx = SessionContext::standalone_with_config(config).await?; +//! +//! // 2. Register a catalog-backed Iceberg table for reads and writes. +//! let props = HashMap::from([("uri".to_string(), "http://localhost:8181".to_string())]); +//! let cfg = IcebergCatalogConfig::new("rest", "rest", props); +//! register_iceberg_table(&ctx, "t", cfg, NamespaceIdent::new("ns".into()), "tbl").await?; +//! +//! // 3. INSERT runs distributed across the cluster. +//! ctx.sql("INSERT INTO t SELECT * FROM source").await?.collect().await?; +//! # Ok(()) +//! # } +//! ``` + +mod bridge; +mod logical_codec; +mod physical_codec; + +use std::sync::Arc; + +use ballista_core::extension::SessionConfigExt; +use datafusion::common::DataFusionError; +use datafusion::prelude::{SessionConfig, SessionContext}; +use iceberg::NamespaceIdent; +pub use iceberg_datafusion::IcebergCatalogConfig; +use iceberg_datafusion::to_datafusion_error; + +pub use crate::logical_codec::IcebergLogicalCodec; +pub use crate::physical_codec::IcebergPhysicalCodec; + +/// Installs the Iceberg logical and physical extension codecs onto a +/// [`SessionConfig`]. +/// +/// In a standalone cluster the scheduler and executor both derive their codecs +/// from this config, so one call suffices. For a separately deployed scheduler +/// and executor, set the same codecs on their process configs +/// (`override_logical_codec` / `override_physical_codec`). +pub fn register_iceberg_codecs(config: SessionConfig) -> SessionConfig { + config + .with_ballista_logical_extension_codec(Arc::new(IcebergLogicalCodec::default())) + .with_ballista_physical_extension_codec(Arc::new(IcebergPhysicalCodec::default())) +} + +/// Builds a catalog-backed [`IcebergTableProvider`](iceberg_datafusion::IcebergTableProvider) +/// from `config` and registers it on `ctx` under `register_name`. +/// +/// The provider carries `config` so its plan nodes can be reconstructed on +/// remote Ballista nodes. +pub async fn register_iceberg_table( + ctx: &SessionContext, + register_name: &str, + config: IcebergCatalogConfig, + namespace: NamespaceIdent, + table: impl Into, +) -> Result<(), DataFusionError> { + let catalog = bridge::build_catalog(&config).await?; + let provider = iceberg_datafusion::IcebergTableProvider::try_new_with_config( + catalog, config, namespace, table, + ) + .await + .map_err(to_datafusion_error)?; + ctx.register_table(register_name, Arc::new(provider))?; + Ok(()) +} + +/// Builds an [`IcebergCatalogProvider`](iceberg_datafusion::IcebergCatalogProvider) +/// from `config` and registers it on `ctx` under `register_name`, mounting the +/// whole Iceberg catalog at once. +/// +/// Every table then resolves as `..` in SQL, +/// and each provider carries `config` so its plan nodes can be reconstructed on +/// remote nodes — including metadata tables such as `
$snapshots`. +pub async fn register_iceberg_catalog( + ctx: &SessionContext, + register_name: &str, + config: IcebergCatalogConfig, +) -> Result<(), DataFusionError> { + let catalog = bridge::build_catalog(&config).await?; + let provider = + iceberg_datafusion::IcebergCatalogProvider::try_new_with_config(catalog, config) + .await + .map_err(to_datafusion_error)?; + ctx.register_catalog(register_name, Arc::new(provider)); + Ok(()) +} diff --git a/integrations/iceberg/src/logical_codec.rs b/integrations/iceberg/src/logical_codec.rs new file mode 100644 index 0000000000..9a0fdaef4a --- /dev/null +++ b/integrations/iceberg/src/logical_codec.rs @@ -0,0 +1,421 @@ +// 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. + +//! Logical extension codec that serializes the catalog-backed +//! [`IcebergTableProvider`] (its [`IcebergCatalogConfig`] + table identifier) so +//! that the Ballista scheduler can rebuild the provider from a logical plan and +//! perform physical planning (including `insert_into`) for Iceberg tables. +//! +//! All other logical-plan serialization (extension nodes, file formats, other +//! table providers) is delegated to an inner codec (by default Ballista's +//! [`BallistaLogicalExtensionCodec`]). + +use std::fmt::Debug; +use std::sync::Arc; + +use ballista_core::serde::BallistaLogicalExtensionCodec; +use datafusion::arrow::datatypes::SchemaRef; +use datafusion::catalog::TableProvider; +use datafusion::common::DataFusionError; +use datafusion::datasource::file_format::FileFormatFactory; +use datafusion::execution::TaskContext; +use datafusion::logical_expr::{Extension, LogicalPlan}; +use datafusion::sql::TableReference; +use datafusion_proto::logical_plan::LogicalExtensionCodec; +use iceberg::TableIdent; +use iceberg::inspect::MetadataTableType; +use iceberg_datafusion::{ + IcebergMetadataTableProvider, IcebergTableProvider, to_datafusion_error, +}; +use serde::{Deserialize, Serialize}; + +use crate::bridge::{ + CatalogConfigWire, Frame, TAG_DELEGATED, block_on, encode_blob, get_catalog, + load_table, split_frame, to_df_err, +}; + +/// Wire representation of an Iceberg table provider. Carries enough to rebuild +/// either the catalog-backed data provider or a metadata-table provider on a +/// remote node. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +enum IcebergProviderWire { + /// The catalog-backed [`IcebergTableProvider`]. + Table { + catalog: CatalogConfigWire, + table: TableIdent, + /// Pinned snapshot for time-travel reads, if any. + #[serde(default)] + snapshot_id: Option, + }, + /// An [`IcebergMetadataTableProvider`] (e.g. `tbl$snapshots`). + Metadata { + catalog: CatalogConfigWire, + table: TableIdent, + /// The metadata table kind, as its lowercase string name. + metadata_type: String, + }, +} + +/// A [`LogicalExtensionCodec`] that understands the catalog-backed +/// [`IcebergTableProvider`] and delegates everything else to an inner codec. +#[derive(Debug)] +pub struct IcebergLogicalCodec { + inner: Arc, +} + +impl Default for IcebergLogicalCodec { + fn default() -> Self { + Self { + inner: Arc::new(BallistaLogicalExtensionCodec::default()), + } + } +} + +impl IcebergLogicalCodec { + /// Creates a codec that delegates non-Iceberg work to `inner`. + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +impl LogicalExtensionCodec for IcebergLogicalCodec { + fn try_decode( + &self, + buf: &[u8], + inputs: &[LogicalPlan], + ctx: &TaskContext, + ) -> Result { + self.inner.try_decode(buf, inputs, ctx) + } + + fn try_encode( + &self, + node: &Extension, + buf: &mut Vec, + ) -> Result<(), DataFusionError> { + self.inner.try_encode(node, buf) + } + + fn try_decode_table_provider( + &self, + buf: &[u8], + table_ref: &TableReference, + schema: SchemaRef, + ctx: &TaskContext, + ) -> Result, DataFusionError> { + match split_frame(buf, "iceberg logical table-provider")? { + Frame::Delegated(rest) => self + .inner + .try_decode_table_provider(rest, table_ref, schema, ctx), + Frame::Iceberg(rest) => { + let wire: IcebergProviderWire = + serde_json::from_slice(rest).map_err(to_df_err)?; + match wire { + IcebergProviderWire::Table { + catalog, + table, + snapshot_id, + } => { + let config = catalog.into(); + let cat = get_catalog(&config)?; + let TableIdent { namespace, name } = table; + // Both steps run on the catalog runtime: `with_snapshot_id` + // reloads metadata to validate the pin, so it is async + // too. It reloads even for `None`, so only call it when + // there is a pin — the provider was just built from a + // fresh load, and skipping it saves a catalog round-trip + // on every unpinned decode. + let provider = block_on(async { + let unpinned = IcebergTableProvider::try_new_with_config( + cat, config, namespace, name, + ) + .await?; + match snapshot_id { + Some(_) => unpinned.with_snapshot_id(snapshot_id).await, + None => Ok(unpinned), + } + }) + .map_err(to_datafusion_error)?; + Ok(Arc::new(provider)) + } + IcebergProviderWire::Metadata { + catalog, + table, + metadata_type, + } => { + let config = catalog.into(); + let (_, table_obj) = load_table(&config, &table)?; + let kind = MetadataTableType::try_from(metadata_type.as_str()) + .map_err(DataFusionError::Internal)?; + let provider = IcebergMetadataTableProvider::new(table_obj, kind) + .with_catalog_config(Some(config)); + Ok(Arc::new(provider)) + } + } + } + } + } + + fn try_encode_table_provider( + &self, + table_ref: &TableReference, + node: Arc, + buf: &mut Vec, + ) -> Result<(), DataFusionError> { + if let Some(provider) = node.downcast_ref::() { + let config = provider.config().ok_or_else(|| { + DataFusionError::Internal( + "IcebergTableProvider has no IcebergCatalogConfig and cannot be \ + distributed; register it with \ + IcebergTableProvider::try_new_with_config (see \ + iceberg_ballista::register_iceberg_table)." + .to_string(), + ) + })?; + let wire = IcebergProviderWire::Table { + catalog: config.into(), + table: provider.table_ident().clone(), + snapshot_id: provider.snapshot_id(), + }; + return encode_blob(buf, &wire); + } + if let Some(provider) = node.downcast_ref::() { + let config = provider.catalog_config().ok_or_else(|| { + DataFusionError::Internal( + "IcebergMetadataTableProvider has no IcebergCatalogConfig and cannot be \ + distributed; register the catalog with \ + IcebergCatalogProvider::try_new_with_config so its tables carry it." + .to_string(), + ) + })?; + let wire = IcebergProviderWire::Metadata { + catalog: config.into(), + table: provider.table().identifier().clone(), + metadata_type: provider.metadata_type().as_str().to_string(), + }; + return encode_blob(buf, &wire); + } + buf.push(TAG_DELEGATED); + self.inner.try_encode_table_provider(table_ref, node, buf) + } + + fn try_decode_file_format( + &self, + buf: &[u8], + ctx: &TaskContext, + ) -> Result, DataFusionError> { + self.inner.try_decode_file_format(buf, ctx) + } + + fn try_encode_file_format( + &self, + buf: &mut Vec, + node: Arc, + ) -> Result<(), DataFusionError> { + self.inner.try_encode_file_format(buf, node) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use datafusion::arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + use datafusion::datasource::empty::EmptyTable; + use datafusion::prelude::SessionContext; + + use crate::bridge::TAG_ICEBERG; + + use super::*; + + fn sample_catalog() -> CatalogConfigWire { + CatalogConfigWire { + r#type: "rest".to_string(), + name: "rest".to_string(), + props: BTreeMap::from([( + "uri".to_string(), + "http://localhost:8181".to_string(), + )]), + } + } + + fn roundtrip(wire: &IcebergProviderWire) -> IcebergProviderWire { + let mut buf = Vec::new(); + encode_blob(&mut buf, wire).expect("encode"); + assert_eq!(buf[0], TAG_ICEBERG, "blob must carry the iceberg tag"); + serde_json::from_slice(&buf[1..]).expect("decode") + } + + #[test] + fn table_provider_wire_roundtrips() { + let wire = IcebergProviderWire::Table { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + snapshot_id: Some(42), + }; + assert_eq!(wire, roundtrip(&wire)); + } + + #[test] + fn metadata_provider_wire_roundtrips() { + let wire = IcebergProviderWire::Metadata { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + metadata_type: "snapshots".to_string(), + }; + assert_eq!(wire, roundtrip(&wire)); + } + + #[test] + fn table_provider_without_snapshot_id_decodes_to_none() { + // `snapshot_id` is `#[serde(default)]`: a payload missing the key still decodes. + let wire = IcebergProviderWire::Table { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + snapshot_id: Some(99), + }; + let mut value = serde_json::to_value(&wire).unwrap(); + value["Table"] + .as_object_mut() + .unwrap() + .remove("snapshot_id"); + + let decoded: IcebergProviderWire = serde_json::from_value(value).expect("decode"); + assert!(matches!( + decoded, + IcebergProviderWire::Table { + snapshot_id: None, + .. + } + )); + } + + /// Stand-in inner codec for the delegation test. The real Ballista codec can't + /// serve here — its `try_encode_table_provider` is a permanent stub — so this + /// mock echoes a marker to prove framing reached it and forwarded the payload. + #[derive(Debug)] + struct MarkerInnerCodec; + + impl LogicalExtensionCodec for MarkerInnerCodec { + fn try_decode( + &self, + _buf: &[u8], + _inputs: &[LogicalPlan], + _ctx: &TaskContext, + ) -> Result { + unreachable!() + } + + fn try_encode( + &self, + _node: &Extension, + _buf: &mut Vec, + ) -> Result<(), DataFusionError> { + unreachable!() + } + + fn try_encode_table_provider( + &self, + _table_ref: &TableReference, + _node: Arc, + buf: &mut Vec, + ) -> Result<(), DataFusionError> { + buf.extend_from_slice(b"INNER-PROVIDER"); + Ok(()) + } + + fn try_decode_table_provider( + &self, + buf: &[u8], + _table_ref: &TableReference, + schema: SchemaRef, + _ctx: &TaskContext, + ) -> Result, DataFusionError> { + assert_eq!( + buf, b"INNER-PROVIDER", + "inner codec must get its bytes, tag stripped" + ); + Ok(Arc::new(EmptyTable::new(schema))) + } + } + + #[test] + fn non_iceberg_table_provider_is_framed_and_delegated_to_inner() { + let schema = Arc::new(ArrowSchema::new(vec![Field::new( + "a", + DataType::Int32, + false, + )])); + let codec = IcebergLogicalCodec::new(Arc::new(MarkerInnerCodec)); + let table_ref = TableReference::bare("t"); + let provider: Arc = Arc::new(EmptyTable::new(schema.clone())); + + let mut buf = Vec::new(); + codec + .try_encode_table_provider(&table_ref, provider, &mut buf) + .expect("encode"); + assert_eq!( + buf[0], TAG_DELEGATED, + "non-Iceberg provider must be delegated" + ); + assert_eq!( + &buf[1..], + b"INNER-PROVIDER", + "inner payload follows the tag" + ); + + let ctx = SessionContext::new(); + let decoded = codec + .try_decode_table_provider(&buf, &table_ref, schema, &ctx.task_ctx()) + .expect("decode"); + assert!(decoded.downcast_ref::().is_some()); + } + + fn one_col_schema() -> SchemaRef { + Arc::new(ArrowSchema::new(vec![Field::new( + "a", + DataType::Int32, + false, + )])) + } + + #[test] + fn try_decode_table_provider_rejects_unframed_buffers() { + // Missing or unrecognized framing must be a hard error, never a misparse + // of whatever bytes follow. + let codec = IcebergLogicalCodec::default(); + let ctx = SessionContext::new(); + let decode = |buf: &[u8]| { + codec.try_decode_table_provider( + buf, + &TableReference::bare("t"), + one_col_schema(), + &ctx.task_ctx(), + ) + }; + + let err = decode(&[]).unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + + let err = decode(&[99]).unwrap_err(); + assert!( + err.to_string() + .contains("unknown iceberg logical table-provider tag 99"), + "{err}" + ); + } +} diff --git a/integrations/iceberg/src/physical_codec.rs b/integrations/iceberg/src/physical_codec.rs new file mode 100644 index 0000000000..c61890de6f --- /dev/null +++ b/integrations/iceberg/src/physical_codec.rs @@ -0,0 +1,675 @@ +// 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. + +//! Physical extension codec for the Iceberg execution plan nodes. +//! +//! Encodes/decodes [`IcebergTableScan`], [`IcebergWriteExec`], and +//! [`IcebergCommitExec`] so Ballista can ship them to remote executors. Any +//! node that is not an Iceberg node is delegated to an inner codec (by default +//! Ballista's own [`BallistaPhysicalExtensionCodec`]), so shuffle and other +//! Ballista plan nodes keep working. + +use std::fmt::Debug; +use std::sync::Arc; + +use ballista_core::serde::BallistaPhysicalExtensionCodec; +use datafusion::arrow::datatypes::SchemaRef; +use datafusion::common::DataFusionError; +use datafusion::execution::TaskContext; +use datafusion::physical_expr::PhysicalExpr; +use datafusion::physical_plan::ExecutionPlan; +use datafusion_proto::physical_plan::PhysicalExtensionCodec; +use iceberg::TableIdent; +use iceberg::expr::Predicate; +use iceberg::inspect::MetadataTableType; +use iceberg::spec::{PartitionSpec, Schema}; +use iceberg_datafusion::physical_plan::{ + IcebergCommitExec, IcebergMetadataScan, IcebergTableScan, IcebergWriteExec, + PartitionExpr, +}; +use iceberg_datafusion::{ + IcebergMetadataTableProvider, snapshot_arrow_schema, to_datafusion_error, +}; +use serde::{Deserialize, Serialize}; + +use crate::bridge::{ + CatalogConfigWire, Frame, TAG_DELEGATED, encode_blob, load_table, split_frame, + to_df_err, +}; + +/// Wire representation of an Iceberg physical plan node. +// `Predicate` is not `Eq` (it can hold float literals), so this derives only +// `PartialEq`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +enum IcebergPhysicalNode { + Scan { + catalog: CatalogConfigWire, + table: TableIdent, + snapshot_id: Option, + projection: Option>, + limit: Option, + /// Pushed-down filter, restored on the remote node so Iceberg file + /// pruning is preserved (DataFusion still re-applies it above the scan). + #[serde(default)] + predicates: Option, + }, + Write { + catalog: CatalogConfigWire, + table: TableIdent, + }, + Commit { + catalog: CatalogConfigWire, + table: TableIdent, + }, + Metadata { + catalog: CatalogConfigWire, + table: TableIdent, + /// The metadata table kind, as its lowercase string name. + metadata_type: String, + }, +} + +/// Wire representation of an [`IcebergDataFusion`](iceberg_datafusion) partition +/// expression. The live `PartitionValueCalculator` it wraps is not serializable, +/// but it can be rebuilt on the far node from the (self-contained) partition spec +/// and table schema, so those are all that travels on the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PartitionExprWire { + partition_spec: PartitionSpec, + schema: Schema, +} + +/// A [`PhysicalExtensionCodec`] that understands the Iceberg plan nodes and +/// delegates everything else to an inner codec. +#[derive(Debug)] +pub struct IcebergPhysicalCodec { + inner: Arc, +} + +impl Default for IcebergPhysicalCodec { + fn default() -> Self { + Self { + inner: Arc::new(BallistaPhysicalExtensionCodec::default()), + } + } +} + +impl IcebergPhysicalCodec { + /// Creates a codec that delegates non-Iceberg nodes to `inner`. + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +fn missing_config_err(node: &str) -> DataFusionError { + DataFusionError::Internal(format!( + "{node} has no IcebergCatalogConfig and cannot be distributed. Register the \ + table with IcebergTableProvider::try_new_with_config (see \ + iceberg_ballista::register_iceberg_table)." + )) +} + +impl PhysicalExtensionCodec for IcebergPhysicalCodec { + fn try_decode( + &self, + buf: &[u8], + inputs: &[Arc], + ctx: &TaskContext, + ) -> Result, DataFusionError> { + let rest = match split_frame(buf, "iceberg physical codec")? { + Frame::Delegated(rest) => return self.inner.try_decode(rest, inputs, ctx), + Frame::Iceberg(rest) => rest, + }; + + let node: IcebergPhysicalNode = + serde_json::from_slice(rest).map_err(to_df_err)?; + + match node { + IcebergPhysicalNode::Scan { + catalog, + table, + snapshot_id, + projection, + limit, + predicates, + } => { + let config = catalog.into(); + let (_, table_obj) = load_table(&config, &table)?; + // A pinned scan must use the schema that snapshot was written + // under — the table's schema may have changed since, and the + // current one would describe historical rows incorrectly. + let arrow_schema = snapshot_arrow_schema(&table_obj, snapshot_id) + .map_err(to_datafusion_error)?; + let proj_indices = project_indices( + &arrow_schema, + projection.as_ref(), + &table, + snapshot_id, + )?; + let scan = IcebergTableScan::new( + table_obj, + snapshot_id, + arrow_schema, + proj_indices.as_ref(), + &[], + limit, + ) + .with_predicates(predicates) + .with_catalog_config(Some(config)); + Ok(Arc::new(scan)) + } + IcebergPhysicalNode::Write { catalog, table } => { + let config = catalog.into(); + let (_, table_obj) = load_table(&config, &table)?; + // Writes always target the current schema — a snapshot-pinned + // provider is read-only. + let arrow_schema = snapshot_arrow_schema(&table_obj, None) + .map_err(to_datafusion_error)?; + let input = single_input(inputs, "IcebergWriteExec")?; + let write = IcebergWriteExec::new(table_obj, input, arrow_schema) + .with_catalog_config(Some(config)); + Ok(Arc::new(write)) + } + IcebergPhysicalNode::Commit { catalog, table } => { + let config = catalog.into(); + let (cat, table_obj) = load_table(&config, &table)?; + let arrow_schema = snapshot_arrow_schema(&table_obj, None) + .map_err(to_datafusion_error)?; + let input = single_input(inputs, "IcebergCommitExec")?; + let commit = IcebergCommitExec::new(table_obj, cat, input, arrow_schema) + .with_catalog_config(Some(config)); + Ok(Arc::new(commit)) + } + IcebergPhysicalNode::Metadata { + catalog, + table, + metadata_type, + } => { + let config = catalog.into(); + let (_, table_obj) = load_table(&config, &table)?; + let kind = MetadataTableType::try_from(metadata_type.as_str()) + .map_err(DataFusionError::Internal)?; + let provider = IcebergMetadataTableProvider::new(table_obj, kind) + .with_catalog_config(Some(config)); + Ok(Arc::new(IcebergMetadataScan::new(provider))) + } + } + } + + fn try_encode( + &self, + node: Arc, + buf: &mut Vec, + ) -> Result<(), DataFusionError> { + if let Some(scan) = node.downcast_ref::() { + let config = scan + .catalog_config() + .ok_or_else(|| missing_config_err("IcebergTableScan"))?; + // Pin the snapshot at encode (planning) time. The executor reloads + // table metadata independently, so an unpinned scan would read + // whatever snapshot is current when each task decodes — concurrent + // commits could then give two tasks of one query different + // snapshots. `scan.table()` is the table as loaded at planning, so + // its current snapshot is the consistent choice for every task. + let snapshot_id = scan + .snapshot_id() + .or_else(|| scan.table().metadata().current_snapshot_id()); + let node = IcebergPhysicalNode::Scan { + catalog: config.into(), + table: scan.table().identifier().clone(), + snapshot_id, + projection: scan.projection().map(|s| s.to_vec()), + limit: scan.limit(), + predicates: scan.predicates().cloned(), + }; + return encode_blob(buf, &node); + } + + if let Some(write) = node.downcast_ref::() { + let config = write + .catalog_config() + .ok_or_else(|| missing_config_err("IcebergWriteExec"))?; + let node = IcebergPhysicalNode::Write { + catalog: config.into(), + table: write.table().identifier().clone(), + }; + return encode_blob(buf, &node); + } + + if let Some(commit) = node.downcast_ref::() { + let config = commit + .catalog_config() + .ok_or_else(|| missing_config_err("IcebergCommitExec"))?; + let node = IcebergPhysicalNode::Commit { + catalog: config.into(), + table: commit.table().identifier().clone(), + }; + return encode_blob(buf, &node); + } + + if let Some(meta) = node.downcast_ref::() { + let provider = meta.provider(); + let config = provider + .catalog_config() + .ok_or_else(|| missing_config_err("IcebergMetadataScan"))?; + let node = IcebergPhysicalNode::Metadata { + catalog: config.into(), + table: provider.table().identifier().clone(), + metadata_type: provider.metadata_type().as_str().to_string(), + }; + return encode_blob(buf, &node); + } + + buf.push(TAG_DELEGATED); + self.inner.try_encode(node, buf) + } + + fn try_encode_expr( + &self, + node: &Arc, + buf: &mut Vec, + ) -> Result<(), DataFusionError> { + // The partition-value expression a partitioned write injects holds a + // live calculator; serialize the spec + schema it can be rebuilt from. + if let Some(expr) = node.downcast_ref::() { + let wire = PartitionExprWire { + partition_spec: expr.partition_spec().as_ref().clone(), + schema: expr.table_schema().as_ref().clone(), + }; + return encode_blob(buf, &wire); + } + buf.push(TAG_DELEGATED); + self.inner.try_encode_expr(node, buf) + } + + fn try_decode_expr( + &self, + buf: &[u8], + inputs: &[Arc], + ) -> Result, DataFusionError> { + match split_frame(buf, "iceberg physical expr")? { + Frame::Delegated(rest) => self.inner.try_decode_expr(rest, inputs), + Frame::Iceberg(rest) => { + let wire: PartitionExprWire = + serde_json::from_slice(rest).map_err(to_df_err)?; + let expr = PartitionExpr::try_new( + Arc::new(wire.partition_spec), + Arc::new(wire.schema), + )?; + Ok(Arc::new(expr)) + } + } + } +} + +/// Maps projected column names back to their indices in `arrow_schema`, the +/// schema of the table at `snapshot_id`. +/// +/// A name that doesn't resolve is a hard error: the executor reloads table +/// metadata independently of the scheduler, so silently dropping it would +/// rebuild the scan with fewer columns than the plan expects and surface later +/// as a confusing column-count mismatch instead of a clear failure here. +/// +/// The usual cause is a schema change with no write behind it. Evolving a schema +/// creates no snapshot, so a scan planned right after an `ADD COLUMN` projects a +/// column that the latest snapshot's schema does not have yet — hence the +/// message points at the snapshot rather than at cluster state. +fn project_indices( + arrow_schema: &SchemaRef, + projection: Option<&Vec>, + table: &TableIdent, + snapshot_id: Option, +) -> Result>, DataFusionError> { + projection + .map(|names| { + names + .iter() + .map(|n| { + arrow_schema.index_of(n).map_err(|_| { + let cause = match snapshot_id { + Some(id) => format!( + "not found in the schema of table {table} at snapshot {id}; \ + the table's schema may have changed since that snapshot \ + was written" + ), + None => format!( + "not found in the current schema of table {table}; \ + scheduler and executor table metadata may be out of sync" + ), + }; + DataFusionError::Internal(format!("projected column {n:?} {cause}")) + }) + }) + .collect::, _>>() + }) + .transpose() +} + +fn single_input( + inputs: &[Arc], + node: &str, +) -> Result, DataFusionError> { + if inputs.len() != 1 { + return Err(DataFusionError::Internal(format!( + "{node} expects exactly one input, got {}", + inputs.len() + ))); + } + Ok(inputs[0].clone()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::bridge::TAG_ICEBERG; + + use super::*; + + fn sample_catalog() -> CatalogConfigWire { + CatalogConfigWire { + r#type: "rest".to_string(), + name: "rest".to_string(), + props: BTreeMap::from([ + ("uri".to_string(), "http://localhost:8181".to_string()), + ("warehouse".to_string(), "s3://bucket/wh".to_string()), + ]), + } + } + + fn roundtrip(node: &IcebergPhysicalNode) -> IcebergPhysicalNode { + let mut buf = Vec::new(); + encode_blob(&mut buf, node).expect("encode"); + assert_eq!(buf[0], TAG_ICEBERG, "blob must carry the iceberg tag"); + serde_json::from_slice(&buf[1..]).expect("decode") + } + + #[test] + fn scan_node_roundtrips() { + let node = IcebergPhysicalNode::Scan { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + snapshot_id: Some(42), + projection: Some(vec!["a".to_string(), "b".to_string()]), + limit: Some(10), + predicates: None, + }; + assert_eq!(node, roundtrip(&node)); + } + + #[test] + fn scan_node_with_predicate_roundtrips() { + use iceberg::expr::Reference; + use iceberg::spec::Datum; + + let node = IcebergPhysicalNode::Scan { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + snapshot_id: None, + projection: None, + limit: None, + predicates: Some(Reference::new("a").less_than(Datum::long(5))), + }; + assert_eq!(node, roundtrip(&node)); + } + + #[test] + fn scan_node_with_compound_predicate_roundtrips() { + use iceberg::expr::Reference; + use iceberg::spec::Datum; + + // Exercise AND / OR / IN / IS NULL together — Predicate is the trickiest type. + let predicate = Reference::new("a") + .less_than(Datum::long(5)) + .and(Reference::new("b").is_null()) + .or(Reference::new("c").is_in([Datum::string("x"), Datum::string("y")])); + let node = IcebergPhysicalNode::Scan { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + snapshot_id: None, + projection: None, + limit: None, + predicates: Some(predicate), + }; + assert_eq!(node, roundtrip(&node)); + } + + #[test] + fn scan_node_without_predicates_field_decodes_to_none() { + use iceberg::expr::Reference; + use iceberg::spec::Datum; + + // `predicates` is `#[serde(default)]`: a payload missing the key still decodes. + let node = IcebergPhysicalNode::Scan { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + snapshot_id: Some(7), + projection: None, + limit: None, + predicates: Some(Reference::new("a").less_than(Datum::long(5))), + }; + let mut value = serde_json::to_value(&node).unwrap(); + value["Scan"].as_object_mut().unwrap().remove("predicates"); + + let decoded: IcebergPhysicalNode = serde_json::from_value(value).expect("decode"); + assert!(matches!( + decoded, + IcebergPhysicalNode::Scan { + predicates: None, + snapshot_id: Some(7), + .. + } + )); + } + + #[test] + fn partition_expr_wire_roundtrips() { + use iceberg::spec::{NestedField, PrimitiveType, Transform, Type}; + + // Schema + PartitionSpec are the heaviest serde types in the crate. + let schema = Schema::builder() + .with_schema_id(0) + .with_fields(vec![ + NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)) + .into(), + NestedField::optional( + 2, + "region", + Type::Primitive(PrimitiveType::String), + ) + .into(), + ]) + .build() + .unwrap(); + let partition_spec = PartitionSpec::builder(schema.clone()) + .with_spec_id(0) + .add_partition_field("region", "region", Transform::Identity) + .unwrap() + .build() + .unwrap(); + + let wire = PartitionExprWire { + partition_spec, + schema, + }; + + let mut buf = Vec::new(); + encode_blob(&mut buf, &wire).expect("encode"); + assert_eq!(buf[0], TAG_ICEBERG, "blob must carry the iceberg tag"); + let decoded: PartitionExprWire = + serde_json::from_slice(&buf[1..]).expect("decode"); + + assert_eq!(decoded.partition_spec, wire.partition_spec); + assert_eq!(decoded.schema, wire.schema); + } + + #[test] + fn metadata_node_roundtrips() { + let node = IcebergPhysicalNode::Metadata { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + metadata_type: "snapshots".to_string(), + }; + assert_eq!(node, roundtrip(&node)); + } + + #[test] + fn write_node_roundtrips() { + let node = IcebergPhysicalNode::Write { + catalog: sample_catalog(), + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + }; + assert_eq!(node, roundtrip(&node)); + } + + #[test] + fn commit_node_roundtrips() { + let node = IcebergPhysicalNode::Commit { + catalog: sample_catalog(), + table: TableIdent::from_strs(["a", "b", "tbl"]).unwrap(), + }; + assert_eq!(node, roundtrip(&node)); + } + + #[test] + fn non_iceberg_node_roundtrips_through_inner_codec() { + use ballista_core::execution_plans::ShuffleWriterExec; + use datafusion::arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + use datafusion::physical_plan::empty::EmptyExec; + use datafusion::prelude::SessionContext; + + // A Ballista shuffle node is not an Iceberg node, so the codec must + // frame it with TAG_DELEGATED and hand it to the inner Ballista codec — + // and decode must route it back there, reconstructing the same node. + let schema = Arc::new(ArrowSchema::new(vec![Field::new( + "a", + DataType::Int32, + false, + )])); + let input: Arc = Arc::new(EmptyExec::new(schema)); + let shuffle = ShuffleWriterExec::try_new( + "job-1".to_string().into(), + 7, + input.clone(), + "/tmp/work".to_string(), + ) + .expect("build shuffle writer"); + + let codec = IcebergPhysicalCodec::default(); + let mut buf = Vec::new(); + codec + .try_encode(Arc::new(shuffle), &mut buf) + .expect("encode delegated node"); + assert_eq!(buf[0], TAG_DELEGATED, "non-Iceberg node must be delegated"); + + let ctx = SessionContext::new(); + let decoded = codec + .try_decode(&buf, &[input], &ctx.task_ctx()) + .expect("decode delegated node"); + let decoded = decoded + .downcast_ref::() + .expect("decoded plan should be a ShuffleWriterExec"); + assert_eq!(decoded.job_id().as_str(), "job-1"); + assert_eq!(decoded.stage_id(), 7); + } + + fn arrow_schema() -> SchemaRef { + use datafusion::arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + Arc::new(ArrowSchema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + Field::new("c", DataType::Int32, false), + ])) + } + + fn tbl() -> TableIdent { + TableIdent::from_strs(["ns", "tbl"]).unwrap() + } + + #[test] + fn project_indices_resolves_names_in_projection_order() { + let names = vec!["c".to_string(), "a".to_string()]; + let idx = project_indices(&arrow_schema(), Some(&names), &tbl(), None).unwrap(); + assert_eq!(idx, Some(vec![2, 0]), "resolved in projection order"); + + // No projection means "all columns", not "no columns". + assert_eq!( + project_indices(&arrow_schema(), None, &tbl(), None).unwrap(), + None + ); + } + + #[test] + fn project_indices_unknown_column_errors() { + // A projected name absent from the reloaded schema must fail loudly, + // naming the column and a cause that fits how the schema was resolved. + let names = vec!["missing".to_string()]; + + let err = + project_indices(&arrow_schema(), Some(&names), &tbl(), Some(42)).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("missing"), "names the column: {msg}"); + assert!(msg.contains("snapshot 42"), "names the snapshot: {msg}"); + assert!( + msg.contains("schema may have changed"), + "the likely cause: {msg}" + ); + + // Unpinned scans resolve against the current schema, where a missing + // column really does mean the two nodes disagree about the table. + let err = + project_indices(&arrow_schema(), Some(&names), &tbl(), None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("out of sync"), "explains the cause: {msg}"); + } + + #[test] + fn try_decode_rejects_unframed_buffers() { + use datafusion::prelude::SessionContext; + + // Missing or unrecognized framing must be a hard error, never a misparse + // of whatever bytes follow. + let ctx = SessionContext::new(); + let codec = IcebergPhysicalCodec::default(); + + let err = codec.try_decode(&[], &[], &ctx.task_ctx()).unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + + let err = codec.try_decode(&[99], &[], &ctx.task_ctx()).unwrap_err(); + assert!( + err.to_string() + .contains("unknown iceberg physical codec tag 99"), + "{err}" + ); + } + + #[test] + fn try_decode_expr_rejects_unframed_buffers() { + // The expr path has its own tag dispatch, so it needs its own check. + let codec = IcebergPhysicalCodec::default(); + + let err = codec.try_decode_expr(&[], &[]).unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + + let err = codec.try_decode_expr(&[99], &[]).unwrap_err(); + assert!( + err.to_string() + .contains("unknown iceberg physical expr tag 99"), + "{err}" + ); + } +} diff --git a/integrations/iceberg/tests/distributed_read_write.rs b/integrations/iceberg/tests/distributed_read_write.rs new file mode 100644 index 0000000000..5fba873d1c --- /dev/null +++ b/integrations/iceberg/tests/distributed_read_write.rs @@ -0,0 +1,744 @@ +// 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. + +//! End-to-end distributed Iceberg write/read test. +//! +//! This runs against the docker fixture in `integrations/iceberg/dev` (an +//! Iceberg REST catalog + MinIO), so it is behind the `integration-tests` +//! feature and is not part of a plain `cargo test`: +//! +//! ```bash +//! docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait +//! cargo test -p iceberg-ballista --features integration-tests --test distributed_read_write +//! ``` +//! +//! The endpoints can be overridden with the `ICEBERG_REST_URI` and +//! `ICEBERG_S3_ENDPOINT` environment variables. + +#![cfg(feature = "integration-tests")] + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use arrow::array::{AsArray, Int64Array, RecordBatch}; +use arrow::datatypes::Int32Type; +use ballista::datafusion::execution::{SessionState, SessionStateBuilder}; +use ballista::datafusion::prelude::{SessionConfig, SessionContext}; +use ballista::prelude::{SessionConfigExt, SessionContextExt}; +use ballista_core::serde::protobuf::scheduler_grpc_client::SchedulerGrpcClient; +use ballista_executor::new_standalone_executor_from_state; +use ballista_scheduler::standalone::new_standalone_scheduler_from_state; +use iceberg::spec::{ + NestedField, PrimitiveType, Schema, Transform, Type, UnboundPartitionField, + UnboundPartitionSpec, +}; +use iceberg::transaction::{AddColumn, ApplyTransactionAction, Transaction}; +use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, TableCreation, TableIdent}; +use iceberg_ballista::{ + IcebergCatalogConfig, register_iceberg_catalog, register_iceberg_codecs, + register_iceberg_table, +}; +use iceberg_catalog_rest::RestCatalogBuilder; +use iceberg_datafusion::IcebergTableProvider; +use iceberg_storage_opendal::OpenDalStorageFactory; +use tokio::sync::Mutex; + +/// Serializes the catalog-mutating tests in this binary. The REST fixture is +/// backed by in-memory SQLite, which rejects concurrent commits with +/// `SQLITE_BUSY`. Each test still exercises parallelism *internally* (multiple +/// write tasks / executors); this only stops the two test cases from committing +/// to the catalog at the same time, so the suite is robust under a parallel test +/// harness (`cargo test`, `nextest`) without relying on `--test-threads=1`. +static CATALOG_GUARD: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// Table name unique per run, so reruns don't collide in the shared catalog. +fn unique_table_name(prefix: &str) -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{prefix}_{millis}") +} + +/// Session state with the Iceberg codecs installed. +fn iceberg_session_state(config: SessionConfig) -> SessionState { + SessionStateBuilder::new() + .with_config(register_iceberg_codecs(config)) + .with_default_features() + .build() +} + +/// Runs a SQL statement to completion and returns its batches. +async fn run_sql(ctx: &SessionContext, sql: &str) -> Vec { + ctx.sql(sql) + .await + .expect("plan sql") + .collect() + .await + .expect("run sql") +} + +/// Extracts the single `i64` value of the first column (a COUNT result). +fn single_i64(batches: &[RecordBatch]) -> i64 { + batches + .iter() + .find_map(|b| { + b.column(0) + .as_any() + .downcast_ref::() + .map(|a| a.value(0)) + }) + .expect("i64 column") +} + +/// Flattens the first column of every batch into a `Vec`. +fn i32_values(batches: &[RecordBatch]) -> Vec { + batches + .iter() + .flat_map(|b| b.column(0).as_primitive::().values().to_vec()) + .collect() +} + +/// Flattens a named string column across all batches, preserving nulls. +fn string_values(batches: &[RecordBatch], column: &str) -> Vec> { + batches + .iter() + .flat_map(|b| { + let idx = b.schema().index_of(column).expect("column present"); + b.column(idx) + .as_string::() + .iter() + .map(|v| v.map(str::to_string)) + .collect::>() + }) + .collect() +} + +fn catalog_props() -> HashMap { + let rest_uri = std::env::var("ICEBERG_REST_URI") + .unwrap_or_else(|_| "http://localhost:8181".to_string()); + let s3_endpoint = std::env::var("ICEBERG_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_string()); + HashMap::from([ + ("uri".to_string(), rest_uri), + ("s3.endpoint".to_string(), s3_endpoint), + ("s3.access-key-id".to_string(), "admin".to_string()), + ("s3.secret-access-key".to_string(), "password".to_string()), + ("s3.region".to_string(), "us-east-1".to_string()), + ("s3.path-style-access".to_string(), "true".to_string()), + ]) +} + +async fn build_rest_catalog(props: &HashMap) -> impl Catalog + use<> { + RestCatalogBuilder::default() + .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 { + customized_credential_load: None, + })) + .load("rest", props.clone()) + .await + .expect("build rest catalog") +} + +/// Ensures the shared test namespace exists, tolerating a concurrent creator +/// (tests run in parallel and share this namespace). +async fn ensure_namespace(catalog: &impl Catalog) -> NamespaceIdent { + let namespace = NamespaceIdent::new("ballista_it".to_string()); + if !catalog.namespace_exists(&namespace).await.unwrap() + && let Err(e) = catalog.create_namespace(&namespace, HashMap::new()).await + && !catalog.namespace_exists(&namespace).await.unwrap() + { + panic!("create namespace: {e}"); + } + namespace +} + +async fn create_table( + props: &HashMap, + table_name: &str, +) -> NamespaceIdent { + let catalog = build_rest_catalog(props).await; + let namespace = ensure_namespace(&catalog).await; + + let schema = Schema::builder() + .with_schema_id(0) + .with_fields(vec![ + NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(), + NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)) + .into(), + ]) + .build() + .unwrap(); + let creation = TableCreation::builder() + .name(table_name.to_string()) + .schema(schema) + .properties(HashMap::new()) + .build(); + catalog + .create_table(&namespace, creation) + .await + .expect("create table"); + + namespace +} + +/// Column names of a result set, in order. +fn column_names(batches: &[RecordBatch]) -> Vec { + batches + .first() + .expect("at least one batch") + .schema() + .fields() + .iter() + .map(|f| f.name().clone()) + .collect() +} + +/// Loads the table from the catalog and returns its current snapshot id. +async fn current_snapshot_id( + props: &HashMap, + namespace: &NamespaceIdent, + table_name: &str, +) -> i64 { + build_rest_catalog(props) + .await + .load_table(&TableIdent::new(namespace.clone(), table_name.to_string())) + .await + .expect("load table") + .metadata() + .current_snapshot_id() + .expect("table has a snapshot") +} + +/// Creates a table partitioned by `region` (identity). A distributed INSERT then +/// fans the rows out to one writer per region — exercising the partition-value +/// expression (`PartitionExpr`) serialization across the cluster. +async fn create_partitioned_table( + props: &HashMap, + table_name: &str, +) -> NamespaceIdent { + let catalog = build_rest_catalog(props).await; + let namespace = ensure_namespace(&catalog).await; + + // Optional (nullable) fields so the schema matches the nullable columns a + // `VALUES` source produces; the partitioned-write path checks nullability. + let schema = Schema::builder() + .with_schema_id(0) + .with_fields(vec![ + NestedField::optional(1, "id", Type::Primitive(PrimitiveType::Int)).into(), + NestedField::optional(2, "region", Type::Primitive(PrimitiveType::String)) + .into(), + ]) + .build() + .unwrap(); + let partition_spec = UnboundPartitionSpec::builder() + .with_spec_id(0) + // The REST catalog requires an explicit partition field-id; the in-memory + // catalog used elsewhere assigns one automatically, but REST does not. + .add_partition_fields([UnboundPartitionField { + source_id: 2, + field_id: Some(1000), + name: "region".to_string(), + transform: Transform::Identity, + }]) + .unwrap() + .build(); + let creation = TableCreation::builder() + .name(table_name.to_string()) + .schema(schema) + .partition_spec(partition_spec) + .properties(HashMap::new()) + .build(); + catalog + .create_table(&namespace, creation) + .await + .expect("create partitioned table"); + + namespace +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn distributed_insert_and_read() { + let _ = env_logger::builder().is_test(true).try_init(); + let _catalog_guard = CATALOG_GUARD.lock().await; + + let props = catalog_props(); + let table_name = unique_table_name("events"); + let namespace = create_table(&props, &table_name).await; + + let state = iceberg_session_state( + SessionConfig::new_with_ballista() + .with_target_partitions(2) + .with_ballista_standalone_parallelism(2), + ); + let ctx = SessionContext::standalone_with_state(state) + .await + .expect("start standalone ballista"); + + let catalog_config = IcebergCatalogConfig::new("rest", "rest", props.clone()); + register_iceberg_table( + &ctx, + "events", + catalog_config, + namespace, + table_name.clone(), + ) + .await + .expect("register iceberg table"); + + // Read *before* any insert. This first read is what makes the re-reads below + // meaningful: it forces the registered provider through a full plan/scan + // cycle while the table is still empty, so a provider that froze its table + // metadata here would keep returning this empty snapshot forever. + let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM events").await); + assert_eq!(count, 0, "table is empty before the first insert"); + + run_sql( + &ctx, + "INSERT INTO events VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')", + ) + .await; + + let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM events").await); + assert_eq!(count, 3, "expected 3 rows after distributed insert"); + + let rows = run_sql(&ctx, "SELECT id, name FROM events ORDER BY id").await; + assert_eq!(i32_values(&rows), vec![1, 2, 3]); + + // Insert again through the same registration and re-read. `scan` reloads + // table metadata from the catalog every time, so each query plans against + // the snapshot current at *that* moment — the codec then pins it for the + // executors. A provider (or a cached catalog/table) that went stale after + // the first read would still report 3 rows here. + run_sql(&ctx, "INSERT INTO events VALUES (4, 'dave'), (5, 'erin')").await; + + let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM events").await); + assert_eq!(count, 5, "re-read must observe the interposed insert"); + + let rows = run_sql(&ctx, "SELECT id, name FROM events ORDER BY id").await; + assert_eq!( + i32_values(&rows), + vec![1, 2, 3, 4, 5], + "re-read returns both the original and the newly inserted rows" + ); + + // Catalog-level registration: mount the whole Iceberg catalog and read the + // same table as `..
`. The providers built through + // the catalog carry the config too, so this distributed read exercises the + // catalog/schema config-threading path end to end. + register_iceberg_catalog( + &ctx, + "ice", + IcebergCatalogConfig::new("rest", "rest", props), + ) + .await + .expect("register iceberg catalog"); + let count = single_i64( + &run_sql( + &ctx, + &format!("SELECT count(*) AS n FROM ice.ballista_it.{table_name}"), + ) + .await, + ); + assert_eq!(count, 5, "catalog-qualified distributed read"); +} + +/// Distributed correctness on a real multi-executor cluster, writing a +/// **partitioned** table. +/// +/// Where [`distributed_insert_and_read`] uses standalone Ballista (one in-process +/// executor) and an unpartitioned table, this stands up a single scheduler with +/// **several in-process executors** and writes a table partitioned by `region`. A +/// partitioned write injects a partition-value expression (`PartitionExpr`) into +/// the physical plan, so this exercises that expression's serialization through +/// the codec on top of the plan-node serialization — and fans the rows out to one +/// writer per region across the executors. +/// +/// The assertions target the correctness properties of a distributed, multi-writer +/// write: +/// 1. the write commits exactly **one** atomic snapshot (not one per task), +/// 2. the parallel writers contributed multiple data files (one per region), and +/// 3. every input row lands exactly once (no loss, no duplication). +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn parallel_multi_executor_insert_commits_all_rows() { + let _ = env_logger::builder().is_test(true).try_init(); + let _catalog_guard = CATALOG_GUARD.lock().await; + + const N_EXECUTORS: usize = 2; + const SLOTS_PER_EXECUTOR: usize = 2; + const WRITE_PARTITIONS: usize = 8; + const REGIONS: [&str; 4] = ["a", "b", "c", "d"]; + // REGIONS.len() * 3. + const TOTAL_ROWS: i32 = 12; + + let props = catalog_props(); + let table_name = unique_table_name("parallel_events"); + let namespace = create_partitioned_table(&props, &table_name).await; + + // --- Bring up one scheduler + N executors in-process (real multi-executor) --- + let state = iceberg_session_state( + SessionConfig::new_with_ballista().with_target_partitions(WRITE_PARTITIONS), + ); + + let scheduler_addr = new_standalone_scheduler_from_state(&state) + .await + .expect("start scheduler"); + let scheduler_url = format!("http://localhost:{}", scheduler_addr.port()); + + let scheduler_client = loop { + match SchedulerGrpcClient::connect(scheduler_url.clone()).await { + Ok(client) => break client, + Err(_) => tokio::time::sleep(Duration::from_millis(100)).await, + } + }; + + // Each executor is a separate service; the scheduler load-balances across + // them via pull-based scheduling. + for _ in 0..N_EXECUTORS { + new_standalone_executor_from_state( + scheduler_client.clone(), + SLOTS_PER_EXECUTOR, + &state, + ) + .await + .expect("start executor"); + } + + let ctx = SessionContext::remote_with_state(&scheduler_url, state) + .await + .expect("connect to scheduler"); + + let catalog_config = IcebergCatalogConfig::new("rest", "rest", props.clone()); + register_iceberg_table( + &ctx, + "target", + catalog_config, + namespace.clone(), + table_name.clone(), + ) + .await + .expect("register iceberg table"); + + // --- Distributed, partitioned INSERT across the multi-executor cluster --- + // A serializable VALUES source whose rows span every region; the partitioned + // write fans them out to one writer per region across the executors, which is + // the path that injects PartitionExpr into the physical plan. (A registered + // MemTable would not work here — it's a custom TableProvider that Ballista + // cannot serialize into the logical plan.) + let values = (0..TOTAL_ROWS) + .map(|i| { + let region = REGIONS[i as usize % REGIONS.len()]; + format!("({}, '{region}')", i + 1) + }) + .collect::>() + .join(", "); + run_sql(&ctx, &format!("INSERT INTO target VALUES {values}")).await; + + // (1) The distributed write committed exactly once (a single atomic + // snapshot), not one commit per task. Checked straight from the catalog so + // it isolates write correctness from the distributed read-back below. + let catalog = build_rest_catalog(&props).await; + let table = catalog + .load_table(&TableIdent::new(namespace.clone(), table_name.clone())) + .await + .expect("load committed table"); + assert_eq!( + table.metadata().snapshots().count(), + 1, + "the distributed write must produce exactly one atomic commit" + ); + + // (2) The parallel writers each produced a data file (one per region), all + // coalesced into that single commit. + let snapshot = table + .metadata() + .current_snapshot() + .expect("current snapshot"); + let mut data_files = 0usize; + let manifest_list = table + .manifest_list_reader(snapshot) + .load() + .await + .expect("load manifest list"); + for entry in manifest_list.entries() { + let manifest = entry + .load_manifest(table.file_io()) + .await + .expect("load manifest"); + data_files += manifest.entries().len(); + } + assert!( + data_files >= 2, + "partitioned write should produce multiple data files, got {data_files}" + ); + + // (3) Every row landed exactly once. + let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM target").await); + assert_eq!(count as i32, TOTAL_ROWS, "row count after parallel insert"); + + let ids = i32_values(&run_sql(&ctx, "SELECT id FROM target ORDER BY id").await); + assert_eq!( + ids, + (1..=TOTAL_ROWS).collect::>(), + "exact id set after parallel insert (no lost or duplicated rows)" + ); + + // (4) Predicate pushdown survives serialization: a WHERE clause is pushed into + // the distributed scan (and re-applied above it), and the result is correct. + let half = TOTAL_ROWS / 2; + let filtered_ids = i32_values( + &run_sql( + &ctx, + &format!("SELECT id FROM target WHERE id <= {half} ORDER BY id"), + ) + .await, + ); + assert_eq!( + filtered_ids, + (1..=half).collect::>(), + "predicate-filtered distributed read" + ); +} + +/// A distributed read pinned to an old snapshot must return that snapshot's rows +/// *and* that snapshot's schema — even after the table's schema has changed. +/// +/// The executor never sees the scheduler's table object; it reloads metadata from +/// the catalog itself. So the pin has to survive both codecs to get there, and an +/// executor that used the table's *current* schema would describe the historical +/// rows wrongly. +/// +/// One table, five phases, so both directions of schema drift are covered by a +/// single cluster: +/// +/// 1. write `{id, name}` -> snapshot 1 +/// 2. add `email`, write again -> snapshot 2; a current read sees all six rows +/// 3. read pinned to snapshot 1 — current schema has a column it lacks +/// 4. drop `name`, leaving `{id, email}` +/// 5. read pinned to snapshot 2 — that snapshot has a column current lacks +/// +/// Phase 3 must come before the drop, while the schemas still differ. Phase 5 is +/// the one an executor can't get right by accident: the current schema has no +/// `name` to project, though the pinned snapshot's rows have the values. +/// +/// Phase 4 drops `name` rather than `email` to work around an unrelated +/// iceberg-rust bug; see the comment there. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn distributed_time_travel_pins_snapshot_schema() { + let _ = env_logger::builder().is_test(true).try_init(); + let _catalog_guard = CATALOG_GUARD.lock().await; + + let props = catalog_props(); + let table_name = unique_table_name("timetravel"); + let namespace = create_table(&props, &table_name).await; + + let state = iceberg_session_state( + SessionConfig::new_with_ballista() + .with_target_partitions(2) + .with_ballista_standalone_parallelism(2), + ); + let ctx = SessionContext::standalone_with_state(state) + .await + .expect("start standalone ballista"); + + let catalog_config = IcebergCatalogConfig::new("rest", "rest", props.clone()); + register_iceberg_table( + &ctx, + "events", + catalog_config.clone(), + namespace.clone(), + table_name.clone(), + ) + .await + .expect("register iceberg table"); + + // Snapshot 1: three rows under the original {id, name} schema. + run_sql( + &ctx, + "INSERT INTO events VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')", + ) + .await; + let snapshot_1 = current_snapshot_id(&props, &namespace, &table_name).await; + + // Evolve the schema: add an `email` column. Snapshot 1 keeps referencing the + // two-column schema, so current and historical schemas now differ. + let catalog: Arc = Arc::new(build_rest_catalog(&props).await); + let table_ident = TableIdent::new(namespace.clone(), table_name.clone()); + let table = catalog.load_table(&table_ident).await.expect("load table"); + let tx = Transaction::new(&table); + let tx = tx + .update_schema() + .add_column(AddColumn::optional( + "email", + Type::Primitive(PrimitiveType::String), + )) + .apply(tx) + .expect("apply schema update"); + tx.commit(catalog.as_ref()).await.expect("commit schema"); + + // A provider registered before the change still exposes the schema it was + // built with, so writing the new column needs a freshly built provider. + let evolved = IcebergTableProvider::try_new_with_config( + catalog.clone(), + catalog_config.clone(), + namespace.clone(), + table_name.clone(), + ) + .await + .expect("build evolved provider"); + ctx.register_table("events_v2", Arc::new(evolved)) + .expect("register evolved provider"); + + // Snapshot 2: three more rows, now carrying `email`. + run_sql( + &ctx, + "INSERT INTO events_v2 VALUES (4, 'dave', 'dave@x.test'), \ + (5, 'erin', 'erin@x.test'), (6, 'frank', 'frank@x.test')", + ) + .await; + let snapshot_2 = current_snapshot_id(&props, &namespace, &table_name).await; + + // The current distributed read sees all six rows under the evolved schema. + let current = run_sql(&ctx, "SELECT * FROM events_v2 ORDER BY id").await; + assert_eq!( + column_names(¤t), + vec!["id", "name", "email"], + "phase 2: current read exposes the evolved schema" + ); + assert_eq!(i32_values(¤t), vec![1, 2, 3, 4, 5, 6]); + + // Phase 3 — pin to snapshot 1, which has no `email` while the table does. + // Must run before the drop below, or the two schemas match again and the + // assertion would pass even for a scan that ignored the pin. + let pinned_v1 = IcebergTableProvider::try_new_with_config( + catalog.clone(), + catalog_config.clone(), + namespace.clone(), + table_name.clone(), + ) + .await + .expect("build provider pinned to snapshot 1") + .with_snapshot_id(Some(snapshot_1)) + .await + .expect("pin snapshot 1"); + ctx.register_table("events_v1", Arc::new(pinned_v1)) + .expect("register provider pinned to snapshot 1"); + + // The pinned read returns that snapshot's rows under that snapshot's schema. + let pinned = run_sql(&ctx, "SELECT * FROM events_v1 ORDER BY id").await; + assert_eq!( + column_names(&pinned), + vec!["id", "name"], + "phase 3: pinned read exposes the schema in effect at that snapshot" + ); + assert_eq!( + i32_values(&pinned), + vec![1, 2, 3], + "phase 3: exact historical row set from the pinned snapshot" + ); + + // Phase 4 — drop `name`, leaving {id, email}. Snapshot 2 keeps referencing + // the three-column schema. + // + // Dropping `email` instead would read more naturally here, but it hits an + // iceberg-rust bug: `UpdateSchema` always emits `AddSchema` followed by + // `SetCurrentSchema { schema_id: -1 }`, i.e. "make the last added schema + // current". Removing `email` reproduces the table's original {id, name} + // schema exactly, and the Java REST catalog treats `AddSchema` for a schema + // it already knows as a no-op — so nothing is "last added" and the commit + // fails with `Cannot set last added schema: no schema has been added`. Any + // drop that returns a table to an earlier schema hits this; the fix is to + // send the resolved schema id rather than -1 when the schema already exists. + // Dropping `name` sidesteps it, since {id, email} is new. + let table = catalog + .load_table(&table_ident) + .await + .expect("reload table"); + let tx = Transaction::new(&table); + let tx = tx + .update_schema() + .delete_column("name") + .apply(tx) + .expect("apply column delete"); + tx.commit(catalog.as_ref()) + .await + .expect("commit delete column"); + + let dropped = IcebergTableProvider::try_new_with_config( + catalog.clone(), + catalog_config.clone(), + namespace.clone(), + table_name.clone(), + ) + .await + .expect("build provider after drop"); + ctx.register_table("events_v3", Arc::new(dropped)) + .expect("register provider after drop"); + + let current = run_sql(&ctx, "SELECT * FROM events_v3 ORDER BY id").await; + assert_eq!( + column_names(¤t), + vec!["id", "email"], + "phase 4: current read reflects the dropped column" + ); + assert_eq!(i32_values(¤t), vec![1, 2, 3, 4, 5, 6]); + + // Phase 5 — pin to snapshot 2, which still has the `name` the table just + // lost. An executor using the current schema has no `name` to project, so + // the query fails outright even though those rows carry the values. + let pinned_v2 = IcebergTableProvider::try_new_with_config( + catalog, + catalog_config, + namespace, + table_name, + ) + .await + .expect("build provider pinned to snapshot 2") + .with_snapshot_id(Some(snapshot_2)) + .await + .expect("pin snapshot 2"); + ctx.register_table("events_v2_pinned", Arc::new(pinned_v2)) + .expect("register provider pinned to snapshot 2"); + + let pinned = run_sql(&ctx, "SELECT * FROM events_v2_pinned ORDER BY id").await; + assert_eq!( + column_names(&pinned), + vec!["id", "name", "email"], + "phase 5: pinned read exposes the column that existed at that snapshot" + ); + assert_eq!(i32_values(&pinned), vec![1, 2, 3, 4, 5, 6]); + assert_eq!( + string_values(&pinned, "name"), + ["alice", "bob", "carol", "dave", "erin", "frank"] + .map(|s| Some(s.to_string())) + .to_vec(), + "phase 5: the dropped column's values still read back from the pinned \ + snapshot" + ); + assert_eq!( + string_values(&pinned, "email"), + vec![ + None, + None, + None, + Some("dave@x.test".to_string()), + Some("erin@x.test".to_string()), + Some("frank@x.test".to_string()), + ], + "phase 5: rows written before `email` existed read back null" + ); +} From bf411a30cf5c9161ec453df0825c6a201ed54691 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 1 Aug 2026 18:42:26 -0400 Subject: [PATCH 2/7] simplifications + cleanups --- integrations/iceberg/src/bridge.rs | 73 ++++++++++++++++--- integrations/iceberg/src/lib.rs | 1 - integrations/iceberg/src/logical_codec.rs | 44 ++++------- integrations/iceberg/src/physical_codec.rs | 52 +++++-------- .../iceberg/tests/distributed_read_write.rs | 69 ++++++++++-------- 5 files changed, 134 insertions(+), 105 deletions(-) diff --git a/integrations/iceberg/src/bridge.rs b/integrations/iceberg/src/bridge.rs index 3c8bf60a12..1e4e587d4c 100644 --- a/integrations/iceberg/src/bridge.rs +++ b/integrations/iceberg/src/bridge.rs @@ -37,20 +37,48 @@ use std::future::Future; use std::sync::{Arc, LazyLock, Mutex}; use datafusion::common::DataFusionError; +use iceberg::inspect::MetadataTableType; use iceberg::table::Table; use iceberg::{Catalog, Error, ErrorKind, TableIdent}; -use iceberg_datafusion::{IcebergCatalogConfig, to_datafusion_error}; +use iceberg_datafusion::{ + IcebergCatalogConfig, IcebergMetadataTableProvider, to_datafusion_error, +}; use iceberg_storage_opendal::OpenDalResolvingStorageFactory; use serde::{Deserialize, Serialize}; -/// Converts a non-Iceberg error (serde, etc.) into a [`DataFusionError`]. -/// Iceberg errors use [`to_datafusion_error`], the workspace's conversion. -pub(crate) fn to_df_err( - e: E, -) -> DataFusionError { +/// Converts a serde error into a [`DataFusionError`]. Deliberately concrete: +/// Iceberg errors must go through [`to_datafusion_error`] instead, so they keep +/// their error kind rather than collapsing into `External`. +pub(crate) fn json_err(e: serde_json::Error) -> DataFusionError { DataFusionError::External(Box::new(e)) } +fn missing_config(node: &str, remedy: &str) -> DataFusionError { + DataFusionError::Internal(format!( + "{node} has no IcebergCatalogConfig and cannot be distributed; {remedy}." + )) +} + +/// Error for a table-level node/provider that carries no +/// [`IcebergCatalogConfig`] and therefore cannot be rebuilt on a remote node. +pub(crate) fn missing_table_config_err(node: &str) -> DataFusionError { + missing_config( + node, + "register the table with IcebergTableProvider::try_new_with_config (see \ + iceberg_ballista::register_iceberg_table)", + ) +} + +/// [`missing_table_config_err`], but for metadata-table nodes/providers, whose +/// config normally arrives via catalog registration. +pub(crate) fn missing_catalog_config_err(node: &str) -> DataFusionError { + missing_config( + node, + "register the catalog with IcebergCatalogProvider::try_new_with_config so \ + its tables carry it (see iceberg_ballista::register_iceberg_catalog)", + ) +} + /// Dedicated process-lived runtime that drives all Iceberg catalog I/O. /// /// A catalog's HTTP/connection pool is bound to the runtime that drives it, so @@ -116,7 +144,7 @@ static CATALOGS: LazyLock>>> = /// Storage is provided by [`OpenDalResolvingStorageFactory`], which picks the /// object-store backend (S3, GCS, Azure, local fs, …) from each file's path /// scheme, configured from the same `props`. So a single code path covers every -/// catalog/storage combination the workspace supports. +/// catalog/storage combination the iceberg crates support. pub(crate) async fn build_catalog( config: &IcebergCatalogConfig, ) -> Result, DataFusionError> { @@ -172,8 +200,8 @@ fn is_retryable(err: &Error) -> bool { /// Returning the catalog matters for that retry: a caller that needs both (the /// commit node holds a catalog handle) must get the *rebuilt* catalog, not the /// stale one a separate [`get_catalog`] call before the eviction would have -/// returned. Callers that only want the table ignore it: `let (_, table) = …`. -pub(crate) fn load_table( +/// returned. Callers that only want the table use [`load_table`]. +pub(crate) fn load_table_with_catalog( config: &IcebergCatalogConfig, ident: &TableIdent, ) -> Result<(Arc, Table), DataFusionError> { @@ -190,6 +218,31 @@ pub(crate) fn load_table( } } +/// [`load_table_with_catalog`] for the callers that only want the table. +pub(crate) fn load_table( + config: &IcebergCatalogConfig, + ident: &TableIdent, +) -> Result { + Ok(load_table_with_catalog(config, ident)?.1) +} + +/// Rebuilds an [`IcebergMetadataTableProvider`] (e.g. `tbl$snapshots`) from its +/// wire parts, re-attaching the config so the provider can be re-encoded on the +/// next hop. Shared by the logical and physical codecs, whose wire formats both +/// carry exactly these fields. +pub(crate) fn build_metadata_provider( + catalog: CatalogConfigWire, + table: &TableIdent, + metadata_type: &str, +) -> Result { + let config = catalog.into(); + let table_obj = load_table(&config, table)?; + let kind = + MetadataTableType::try_from(metadata_type).map_err(DataFusionError::Internal)?; + Ok(IcebergMetadataTableProvider::new(table_obj, kind) + .with_catalog_config(Some(config))) +} + // --------------------------------------------------------------------------- // Wire format // --------------------------------------------------------------------------- @@ -211,7 +264,7 @@ pub(crate) fn encode_blob( payload: &T, ) -> Result<(), DataFusionError> { buf.push(TAG_ICEBERG); - buf.extend_from_slice(&serde_json::to_vec(payload).map_err(to_df_err)?); + buf.extend_from_slice(&serde_json::to_vec(payload).map_err(json_err)?); Ok(()) } diff --git a/integrations/iceberg/src/lib.rs b/integrations/iceberg/src/lib.rs index 9099d75e70..4469f6a61b 100644 --- a/integrations/iceberg/src/lib.rs +++ b/integrations/iceberg/src/lib.rs @@ -29,7 +29,6 @@ //! //! ```ignore //! use std::collections::HashMap; -//! use std::sync::Arc; //! //! use iceberg_ballista::{register_iceberg_codecs, register_iceberg_table, IcebergCatalogConfig}; //! use ballista_core::extension::SessionConfigExt; diff --git a/integrations/iceberg/src/logical_codec.rs b/integrations/iceberg/src/logical_codec.rs index 9a0fdaef4a..4a8d60dcd7 100644 --- a/integrations/iceberg/src/logical_codec.rs +++ b/integrations/iceberg/src/logical_codec.rs @@ -24,7 +24,6 @@ //! table providers) is delegated to an inner codec (by default Ballista's //! [`BallistaLogicalExtensionCodec`]). -use std::fmt::Debug; use std::sync::Arc; use ballista_core::serde::BallistaLogicalExtensionCodec; @@ -37,15 +36,15 @@ use datafusion::logical_expr::{Extension, LogicalPlan}; use datafusion::sql::TableReference; use datafusion_proto::logical_plan::LogicalExtensionCodec; use iceberg::TableIdent; -use iceberg::inspect::MetadataTableType; use iceberg_datafusion::{ IcebergMetadataTableProvider, IcebergTableProvider, to_datafusion_error, }; use serde::{Deserialize, Serialize}; use crate::bridge::{ - CatalogConfigWire, Frame, TAG_DELEGATED, block_on, encode_blob, get_catalog, - load_table, split_frame, to_df_err, + CatalogConfigWire, Frame, TAG_DELEGATED, block_on, build_metadata_provider, + encode_blob, get_catalog, json_err, missing_catalog_config_err, + missing_table_config_err, split_frame, }; /// Wire representation of an Iceberg table provider. Carries enough to rebuild @@ -123,7 +122,7 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { .try_decode_table_provider(rest, table_ref, schema, ctx), Frame::Iceberg(rest) => { let wire: IcebergProviderWire = - serde_json::from_slice(rest).map_err(to_df_err)?; + serde_json::from_slice(rest).map_err(json_err)?; match wire { IcebergProviderWire::Table { catalog, @@ -145,7 +144,7 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { ) .await?; match snapshot_id { - Some(_) => unpinned.with_snapshot_id(snapshot_id).await, + Some(id) => unpinned.with_snapshot_id(Some(id)).await, None => Ok(unpinned), } }) @@ -156,15 +155,11 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { catalog, table, metadata_type, - } => { - let config = catalog.into(); - let (_, table_obj) = load_table(&config, &table)?; - let kind = MetadataTableType::try_from(metadata_type.as_str()) - .map_err(DataFusionError::Internal)?; - let provider = IcebergMetadataTableProvider::new(table_obj, kind) - .with_catalog_config(Some(config)); - Ok(Arc::new(provider)) - } + } => Ok(Arc::new(build_metadata_provider( + catalog, + &table, + &metadata_type, + )?)), } } } @@ -177,15 +172,9 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { buf: &mut Vec, ) -> Result<(), DataFusionError> { if let Some(provider) = node.downcast_ref::() { - let config = provider.config().ok_or_else(|| { - DataFusionError::Internal( - "IcebergTableProvider has no IcebergCatalogConfig and cannot be \ - distributed; register it with \ - IcebergTableProvider::try_new_with_config (see \ - iceberg_ballista::register_iceberg_table)." - .to_string(), - ) - })?; + let config = provider + .config() + .ok_or_else(|| missing_table_config_err("IcebergTableProvider"))?; let wire = IcebergProviderWire::Table { catalog: config.into(), table: provider.table_ident().clone(), @@ -195,12 +184,7 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { } if let Some(provider) = node.downcast_ref::() { let config = provider.catalog_config().ok_or_else(|| { - DataFusionError::Internal( - "IcebergMetadataTableProvider has no IcebergCatalogConfig and cannot be \ - distributed; register the catalog with \ - IcebergCatalogProvider::try_new_with_config so its tables carry it." - .to_string(), - ) + missing_catalog_config_err("IcebergMetadataTableProvider") })?; let wire = IcebergProviderWire::Metadata { catalog: config.into(), diff --git a/integrations/iceberg/src/physical_codec.rs b/integrations/iceberg/src/physical_codec.rs index c61890de6f..5f928e1820 100644 --- a/integrations/iceberg/src/physical_codec.rs +++ b/integrations/iceberg/src/physical_codec.rs @@ -23,7 +23,6 @@ //! Ballista's own [`BallistaPhysicalExtensionCodec`]), so shuffle and other //! Ballista plan nodes keep working. -use std::fmt::Debug; use std::sync::Arc; use ballista_core::serde::BallistaPhysicalExtensionCodec; @@ -35,20 +34,18 @@ use datafusion::physical_plan::ExecutionPlan; use datafusion_proto::physical_plan::PhysicalExtensionCodec; use iceberg::TableIdent; use iceberg::expr::Predicate; -use iceberg::inspect::MetadataTableType; use iceberg::spec::{PartitionSpec, Schema}; use iceberg_datafusion::physical_plan::{ IcebergCommitExec, IcebergMetadataScan, IcebergTableScan, IcebergWriteExec, PartitionExpr, }; -use iceberg_datafusion::{ - IcebergMetadataTableProvider, snapshot_arrow_schema, to_datafusion_error, -}; +use iceberg_datafusion::{snapshot_arrow_schema, to_datafusion_error}; use serde::{Deserialize, Serialize}; use crate::bridge::{ - CatalogConfigWire, Frame, TAG_DELEGATED, encode_blob, load_table, split_frame, - to_df_err, + CatalogConfigWire, Frame, TAG_DELEGATED, build_metadata_provider, encode_blob, + json_err, load_table, load_table_with_catalog, missing_catalog_config_err, + missing_table_config_err, split_frame, }; /// Wire representation of an Iceberg physical plan node. @@ -115,14 +112,6 @@ impl IcebergPhysicalCodec { } } -fn missing_config_err(node: &str) -> DataFusionError { - DataFusionError::Internal(format!( - "{node} has no IcebergCatalogConfig and cannot be distributed. Register the \ - table with IcebergTableProvider::try_new_with_config (see \ - iceberg_ballista::register_iceberg_table)." - )) -} - impl PhysicalExtensionCodec for IcebergPhysicalCodec { fn try_decode( &self, @@ -135,8 +124,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { Frame::Iceberg(rest) => rest, }; - let node: IcebergPhysicalNode = - serde_json::from_slice(rest).map_err(to_df_err)?; + let node: IcebergPhysicalNode = serde_json::from_slice(rest).map_err(json_err)?; match node { IcebergPhysicalNode::Scan { @@ -148,7 +136,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { predicates, } => { let config = catalog.into(); - let (_, table_obj) = load_table(&config, &table)?; + let table_obj = load_table(&config, &table)?; // A pinned scan must use the schema that snapshot was written // under — the table's schema may have changed since, and the // current one would describe historical rows incorrectly. @@ -174,7 +162,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { } IcebergPhysicalNode::Write { catalog, table } => { let config = catalog.into(); - let (_, table_obj) = load_table(&config, &table)?; + let table_obj = load_table(&config, &table)?; // Writes always target the current schema — a snapshot-pinned // provider is read-only. let arrow_schema = snapshot_arrow_schema(&table_obj, None) @@ -186,7 +174,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { } IcebergPhysicalNode::Commit { catalog, table } => { let config = catalog.into(); - let (cat, table_obj) = load_table(&config, &table)?; + let (cat, table_obj) = load_table_with_catalog(&config, &table)?; let arrow_schema = snapshot_arrow_schema(&table_obj, None) .map_err(to_datafusion_error)?; let input = single_input(inputs, "IcebergCommitExec")?; @@ -198,15 +186,11 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { catalog, table, metadata_type, - } => { - let config = catalog.into(); - let (_, table_obj) = load_table(&config, &table)?; - let kind = MetadataTableType::try_from(metadata_type.as_str()) - .map_err(DataFusionError::Internal)?; - let provider = IcebergMetadataTableProvider::new(table_obj, kind) - .with_catalog_config(Some(config)); - Ok(Arc::new(IcebergMetadataScan::new(provider))) - } + } => Ok(Arc::new(IcebergMetadataScan::new(build_metadata_provider( + catalog, + &table, + &metadata_type, + )?))), } } @@ -218,7 +202,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { if let Some(scan) = node.downcast_ref::() { let config = scan .catalog_config() - .ok_or_else(|| missing_config_err("IcebergTableScan"))?; + .ok_or_else(|| missing_table_config_err("IcebergTableScan"))?; // Pin the snapshot at encode (planning) time. The executor reloads // table metadata independently, so an unpinned scan would read // whatever snapshot is current when each task decodes — concurrent @@ -242,7 +226,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { if let Some(write) = node.downcast_ref::() { let config = write .catalog_config() - .ok_or_else(|| missing_config_err("IcebergWriteExec"))?; + .ok_or_else(|| missing_table_config_err("IcebergWriteExec"))?; let node = IcebergPhysicalNode::Write { catalog: config.into(), table: write.table().identifier().clone(), @@ -253,7 +237,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { if let Some(commit) = node.downcast_ref::() { let config = commit .catalog_config() - .ok_or_else(|| missing_config_err("IcebergCommitExec"))?; + .ok_or_else(|| missing_table_config_err("IcebergCommitExec"))?; let node = IcebergPhysicalNode::Commit { catalog: config.into(), table: commit.table().identifier().clone(), @@ -265,7 +249,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { let provider = meta.provider(); let config = provider .catalog_config() - .ok_or_else(|| missing_config_err("IcebergMetadataScan"))?; + .ok_or_else(|| missing_catalog_config_err("IcebergMetadataScan"))?; let node = IcebergPhysicalNode::Metadata { catalog: config.into(), table: provider.table().identifier().clone(), @@ -305,7 +289,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { Frame::Delegated(rest) => self.inner.try_decode_expr(rest, inputs), Frame::Iceberg(rest) => { let wire: PartitionExprWire = - serde_json::from_slice(rest).map_err(to_df_err)?; + serde_json::from_slice(rest).map_err(json_err)?; let expr = PartitionExpr::try_new( Arc::new(wire.partition_spec), Arc::new(wire.schema), diff --git a/integrations/iceberg/tests/distributed_read_write.rs b/integrations/iceberg/tests/distributed_read_write.rs index 5fba873d1c..eabdf10fc4 100644 --- a/integrations/iceberg/tests/distributed_read_write.rs +++ b/integrations/iceberg/tests/distributed_read_write.rs @@ -167,13 +167,37 @@ async fn ensure_namespace(catalog: &impl Catalog) -> NamespaceIdent { namespace } -async fn create_table( +/// Creates `table_name` with `schema` (optionally partitioned) in the shared +/// test namespace and returns that namespace. +async fn create_table_with( props: &HashMap, table_name: &str, + schema: Schema, + partition_spec: Option, ) -> NamespaceIdent { let catalog = build_rest_catalog(props).await; let namespace = ensure_namespace(&catalog).await; + let builder = TableCreation::builder() + .name(table_name.to_string()) + .schema(schema) + .properties(HashMap::new()); + let creation = match partition_spec { + Some(spec) => builder.partition_spec(spec).build(), + None => builder.build(), + }; + catalog + .create_table(&namespace, creation) + .await + .expect("create table"); + + namespace +} + +async fn create_table( + props: &HashMap, + table_name: &str, +) -> NamespaceIdent { let schema = Schema::builder() .with_schema_id(0) .with_fields(vec![ @@ -183,17 +207,7 @@ async fn create_table( ]) .build() .unwrap(); - let creation = TableCreation::builder() - .name(table_name.to_string()) - .schema(schema) - .properties(HashMap::new()) - .build(); - catalog - .create_table(&namespace, creation) - .await - .expect("create table"); - - namespace + create_table_with(props, table_name, schema, None).await } /// Column names of a result set, in order. @@ -231,9 +245,6 @@ async fn create_partitioned_table( props: &HashMap, table_name: &str, ) -> NamespaceIdent { - let catalog = build_rest_catalog(props).await; - let namespace = ensure_namespace(&catalog).await; - // Optional (nullable) fields so the schema matches the nullable columns a // `VALUES` source produces; the partitioned-write path checks nullability. let schema = Schema::builder() @@ -247,8 +258,8 @@ async fn create_partitioned_table( .unwrap(); let partition_spec = UnboundPartitionSpec::builder() .with_spec_id(0) - // The REST catalog requires an explicit partition field-id; the in-memory - // catalog used elsewhere assigns one automatically, but REST does not. + // The REST catalog requires an explicit partition field-id (some + // catalogs assign one automatically; REST does not). .add_partition_fields([UnboundPartitionField { source_id: 2, field_id: Some(1000), @@ -257,18 +268,7 @@ async fn create_partitioned_table( }]) .unwrap() .build(); - let creation = TableCreation::builder() - .name(table_name.to_string()) - .schema(schema) - .partition_spec(partition_spec) - .properties(HashMap::new()) - .build(); - catalog - .create_table(&namespace, creation) - .await - .expect("create partitioned table"); - - namespace + create_table_with(props, table_name, schema, Some(partition_spec)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -399,10 +399,19 @@ async fn parallel_multi_executor_insert_commits_all_rows() { .expect("start scheduler"); let scheduler_url = format!("http://localhost:{}", scheduler_addr.port()); + // Bounded so a scheduler that never comes up fails the test instead of + // hanging it. + let mut attempts = 0; let scheduler_client = loop { match SchedulerGrpcClient::connect(scheduler_url.clone()).await { Ok(client) => break client, - Err(_) => tokio::time::sleep(Duration::from_millis(100)).await, + Err(e) if attempts >= 100 => { + panic!("scheduler at {scheduler_url} unreachable after 10s: {e}") + } + Err(_) => { + attempts += 1; + tokio::time::sleep(Duration::from_millis(100)).await; + } } }; From ea90ab7142eca758d6b5c715d5cedaee13333ba0 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 1 Aug 2026 21:05:12 -0400 Subject: [PATCH 3/7] cache pinned tables for scans --- integrations/iceberg/src/bridge.rs | 82 ++++++++++++++- integrations/iceberg/src/logical_codec.rs | 77 ++++++++------ integrations/iceberg/src/physical_codec.rs | 117 +++++++++++---------- 3 files changed, 186 insertions(+), 90 deletions(-) diff --git a/integrations/iceberg/src/bridge.rs b/integrations/iceberg/src/bridge.rs index 1e4e587d4c..ac5bb236d2 100644 --- a/integrations/iceberg/src/bridge.rs +++ b/integrations/iceberg/src/bridge.rs @@ -226,17 +226,68 @@ pub(crate) fn load_table( Ok(load_table_with_catalog(config, ident)?.1) } +/// Most recent snapshot-pinned [`Table`] per (catalog, table), serving scan +/// decodes. +/// +/// The scheduler pins every scan to a snapshot at encode time and each task of +/// the stage decodes that same pin, so one query otherwise triggers +/// tasks-per-stage identical `load_table` round trips. Everything a scan +/// consumes — schema, manifests, data files — derives from the immutable pinned +/// snapshot, so serving the table loaded for that pin from a cache cannot go +/// stale. Keeping only the latest pin per table bounds the cache: a later query +/// pins a newer snapshot and replaces the entry. +#[expect(clippy::type_complexity, reason = "local cache type, never escapes")] +static PINNED_TABLES: LazyLock< + Mutex>, +> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Loads the [`Table`] for a snapshot-pinned scan, served from +/// [`PINNED_TABLES`] when the pin matches the cached entry. +/// +/// Only scans may use this. An unpinned scan (`snapshot_id` = `None`, a table +/// with no snapshot yet) means "current state" and falls through to a fresh +/// load — as must writes, commits, and metadata tables, which have to observe +/// current metadata and use [`load_table`] / [`load_table_with_catalog`] +/// directly. +pub(crate) fn load_table_pinned( + config: &IcebergCatalogConfig, + ident: &TableIdent, + snapshot_id: Option, +) -> Result { + let Some(pin) = snapshot_id else { + return load_table(config, ident); + }; + let key = (CatalogConfigWire::from(config), ident.clone()); + if let Some((cached_pin, table)) = PINNED_TABLES.lock().unwrap().get(&key) + && *cached_pin == pin + { + return Ok(table.clone()); + } + let table = load_table(config, ident)?; + // Cache only when the loaded metadata actually contains the pin. It + // normally does — the pin came from a load the scheduler did moments ago — + // but a lagging catalog could serve metadata from before the snapshot, and + // caching that would pin the staleness for every later task instead of + // letting retries see the snapshot appear. + if table.metadata().snapshot_by_id(pin).is_some() { + PINNED_TABLES + .lock() + .unwrap() + .insert(key, (pin, table.clone())); + } + Ok(table) +} + /// Rebuilds an [`IcebergMetadataTableProvider`] (e.g. `tbl$snapshots`) from its /// wire parts, re-attaching the config so the provider can be re-encoded on the /// next hop. Shared by the logical and physical codecs, whose wire formats both /// carry exactly these fields. pub(crate) fn build_metadata_provider( - catalog: CatalogConfigWire, - table: &TableIdent, + table_ref: TableRefWire, metadata_type: &str, ) -> Result { - let config = catalog.into(); - let table_obj = load_table(&config, table)?; + let (config, table) = table_ref.into_parts(); + let table_obj = load_table(&config, &table)?; let kind = MetadataTableType::try_from(metadata_type).map_err(DataFusionError::Internal)?; Ok(IcebergMetadataTableProvider::new(table_obj, kind) @@ -324,6 +375,29 @@ impl From for IcebergCatalogConfig { } } +/// The `(catalog, table)` header every Iceberg wire payload begins with. +/// +/// `#[serde(flatten)]`ed into each wire variant, so the JSON is identical to +/// spelling the two fields inline — this is a code-dedup device, not a wire +/// change. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct TableRefWire { + pub catalog: CatalogConfigWire, + pub table: TableIdent, +} + +impl TableRefWire { + pub(crate) fn new(config: &IcebergCatalogConfig, table: &TableIdent) -> Self { + Self { + catalog: config.into(), + table: table.clone(), + } + } + pub(crate) fn into_parts(self) -> (IcebergCatalogConfig, TableIdent) { + (self.catalog.into(), self.table) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/integrations/iceberg/src/logical_codec.rs b/integrations/iceberg/src/logical_codec.rs index 4a8d60dcd7..073943093f 100644 --- a/integrations/iceberg/src/logical_codec.rs +++ b/integrations/iceberg/src/logical_codec.rs @@ -42,9 +42,9 @@ use iceberg_datafusion::{ use serde::{Deserialize, Serialize}; use crate::bridge::{ - CatalogConfigWire, Frame, TAG_DELEGATED, block_on, build_metadata_provider, - encode_blob, get_catalog, json_err, missing_catalog_config_err, - missing_table_config_err, split_frame, + Frame, TAG_DELEGATED, TableRefWire, block_on, build_metadata_provider, encode_blob, + get_catalog, json_err, missing_catalog_config_err, missing_table_config_err, + split_frame, }; /// Wire representation of an Iceberg table provider. Carries enough to rebuild @@ -54,16 +54,16 @@ use crate::bridge::{ enum IcebergProviderWire { /// The catalog-backed [`IcebergTableProvider`]. Table { - catalog: CatalogConfigWire, - table: TableIdent, + #[serde(flatten)] + table_ref: TableRefWire, /// Pinned snapshot for time-travel reads, if any. #[serde(default)] snapshot_id: Option, }, /// An [`IcebergMetadataTableProvider`] (e.g. `tbl$snapshots`). Metadata { - catalog: CatalogConfigWire, - table: TableIdent, + #[serde(flatten)] + table_ref: TableRefWire, /// The metadata table kind, as its lowercase string name. metadata_type: String, }, @@ -125,11 +125,10 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { serde_json::from_slice(rest).map_err(json_err)?; match wire { IcebergProviderWire::Table { - catalog, - table, + table_ref, snapshot_id, } => { - let config = catalog.into(); + let (config, table) = table_ref.into_parts(); let cat = get_catalog(&config)?; let TableIdent { namespace, name } = table; // Both steps run on the catalog runtime: `with_snapshot_id` @@ -152,12 +151,10 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { Ok(Arc::new(provider)) } IcebergProviderWire::Metadata { - catalog, - table, + table_ref, metadata_type, } => Ok(Arc::new(build_metadata_provider( - catalog, - &table, + table_ref, &metadata_type, )?)), } @@ -176,8 +173,7 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { .config() .ok_or_else(|| missing_table_config_err("IcebergTableProvider"))?; let wire = IcebergProviderWire::Table { - catalog: config.into(), - table: provider.table_ident().clone(), + table_ref: TableRefWire::new(config, provider.table_ident()), snapshot_id: provider.snapshot_id(), }; return encode_blob(buf, &wire); @@ -187,8 +183,7 @@ impl LogicalExtensionCodec for IcebergLogicalCodec { missing_catalog_config_err("IcebergMetadataTableProvider") })?; let wire = IcebergProviderWire::Metadata { - catalog: config.into(), - table: provider.table().identifier().clone(), + table_ref: TableRefWire::new(config, provider.table().identifier()), metadata_type: provider.metadata_type().as_str().to_string(), }; return encode_blob(buf, &wire); @@ -222,18 +217,21 @@ mod tests { use datafusion::datasource::empty::EmptyTable; use datafusion::prelude::SessionContext; - use crate::bridge::TAG_ICEBERG; + use crate::bridge::{CatalogConfigWire, TAG_ICEBERG}; use super::*; - fn sample_catalog() -> CatalogConfigWire { - CatalogConfigWire { - r#type: "rest".to_string(), - name: "rest".to_string(), - props: BTreeMap::from([( - "uri".to_string(), - "http://localhost:8181".to_string(), - )]), + fn sample_table_ref() -> TableRefWire { + TableRefWire { + catalog: CatalogConfigWire { + r#type: "rest".to_string(), + name: "rest".to_string(), + props: BTreeMap::from([( + "uri".to_string(), + "http://localhost:8181".to_string(), + )]), + }, + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), } } @@ -247,8 +245,7 @@ mod tests { #[test] fn table_provider_wire_roundtrips() { let wire = IcebergProviderWire::Table { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), snapshot_id: Some(42), }; assert_eq!(wire, roundtrip(&wire)); @@ -257,19 +254,33 @@ mod tests { #[test] fn metadata_provider_wire_roundtrips() { let wire = IcebergProviderWire::Metadata { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), metadata_type: "snapshots".to_string(), }; assert_eq!(wire, roundtrip(&wire)); } + #[test] + fn table_ref_flattens_to_inline_catalog_and_table_keys() { + // Wire compat: `TableRefWire` must serialize as inline `catalog` and + // `table` keys, exactly as when the variants spelled the two fields + // out — never nested under a `table_ref` object. + let wire = IcebergProviderWire::Table { + table_ref: sample_table_ref(), + snapshot_id: Some(42), + }; + let value = serde_json::to_value(&wire).unwrap(); + let obj = value["Table"].as_object().unwrap(); + assert!(obj.contains_key("catalog"), "{value}"); + assert!(obj.contains_key("table"), "{value}"); + assert!(!obj.contains_key("table_ref"), "{value}"); + } + #[test] fn table_provider_without_snapshot_id_decodes_to_none() { // `snapshot_id` is `#[serde(default)]`: a payload missing the key still decodes. let wire = IcebergProviderWire::Table { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), snapshot_id: Some(99), }; let mut value = serde_json::to_value(&wire).unwrap(); diff --git a/integrations/iceberg/src/physical_codec.rs b/integrations/iceberg/src/physical_codec.rs index 5f928e1820..47204f076b 100644 --- a/integrations/iceberg/src/physical_codec.rs +++ b/integrations/iceberg/src/physical_codec.rs @@ -43,8 +43,8 @@ use iceberg_datafusion::{snapshot_arrow_schema, to_datafusion_error}; use serde::{Deserialize, Serialize}; use crate::bridge::{ - CatalogConfigWire, Frame, TAG_DELEGATED, build_metadata_provider, encode_blob, - json_err, load_table, load_table_with_catalog, missing_catalog_config_err, + Frame, TAG_DELEGATED, TableRefWire, build_metadata_provider, encode_blob, json_err, + load_table, load_table_pinned, load_table_with_catalog, missing_catalog_config_err, missing_table_config_err, split_frame, }; @@ -54,8 +54,8 @@ use crate::bridge::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] enum IcebergPhysicalNode { Scan { - catalog: CatalogConfigWire, - table: TableIdent, + #[serde(flatten)] + table_ref: TableRefWire, snapshot_id: Option, projection: Option>, limit: Option, @@ -65,16 +65,16 @@ enum IcebergPhysicalNode { predicates: Option, }, Write { - catalog: CatalogConfigWire, - table: TableIdent, + #[serde(flatten)] + table_ref: TableRefWire, }, Commit { - catalog: CatalogConfigWire, - table: TableIdent, + #[serde(flatten)] + table_ref: TableRefWire, }, Metadata { - catalog: CatalogConfigWire, - table: TableIdent, + #[serde(flatten)] + table_ref: TableRefWire, /// The metadata table kind, as its lowercase string name. metadata_type: String, }, @@ -128,15 +128,16 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { match node { IcebergPhysicalNode::Scan { - catalog, - table, + table_ref, snapshot_id, projection, limit, predicates, } => { - let config = catalog.into(); - let table_obj = load_table(&config, &table)?; + let (config, table) = table_ref.into_parts(); + // Pinned loads are cached: every task of the stage decodes the + // same pin, so only the first pays the catalog round trip. + let table_obj = load_table_pinned(&config, &table, snapshot_id)?; // A pinned scan must use the schema that snapshot was written // under — the table's schema may have changed since, and the // current one would describe historical rows incorrectly. @@ -160,8 +161,8 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { .with_catalog_config(Some(config)); Ok(Arc::new(scan)) } - IcebergPhysicalNode::Write { catalog, table } => { - let config = catalog.into(); + IcebergPhysicalNode::Write { table_ref } => { + let (config, table) = table_ref.into_parts(); let table_obj = load_table(&config, &table)?; // Writes always target the current schema — a snapshot-pinned // provider is read-only. @@ -172,8 +173,8 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { .with_catalog_config(Some(config)); Ok(Arc::new(write)) } - IcebergPhysicalNode::Commit { catalog, table } => { - let config = catalog.into(); + IcebergPhysicalNode::Commit { table_ref } => { + let (config, table) = table_ref.into_parts(); let (cat, table_obj) = load_table_with_catalog(&config, &table)?; let arrow_schema = snapshot_arrow_schema(&table_obj, None) .map_err(to_datafusion_error)?; @@ -183,12 +184,10 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { Ok(Arc::new(commit)) } IcebergPhysicalNode::Metadata { - catalog, - table, + table_ref, metadata_type, } => Ok(Arc::new(IcebergMetadataScan::new(build_metadata_provider( - catalog, - &table, + table_ref, &metadata_type, )?))), } @@ -213,8 +212,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { .snapshot_id() .or_else(|| scan.table().metadata().current_snapshot_id()); let node = IcebergPhysicalNode::Scan { - catalog: config.into(), - table: scan.table().identifier().clone(), + table_ref: TableRefWire::new(config, scan.table().identifier()), snapshot_id, projection: scan.projection().map(|s| s.to_vec()), limit: scan.limit(), @@ -228,8 +226,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { .catalog_config() .ok_or_else(|| missing_table_config_err("IcebergWriteExec"))?; let node = IcebergPhysicalNode::Write { - catalog: config.into(), - table: write.table().identifier().clone(), + table_ref: TableRefWire::new(config, write.table().identifier()), }; return encode_blob(buf, &node); } @@ -239,8 +236,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { .catalog_config() .ok_or_else(|| missing_table_config_err("IcebergCommitExec"))?; let node = IcebergPhysicalNode::Commit { - catalog: config.into(), - table: commit.table().identifier().clone(), + table_ref: TableRefWire::new(config, commit.table().identifier()), }; return encode_blob(buf, &node); } @@ -251,8 +247,7 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { .catalog_config() .ok_or_else(|| missing_catalog_config_err("IcebergMetadataScan"))?; let node = IcebergPhysicalNode::Metadata { - catalog: config.into(), - table: provider.table().identifier().clone(), + table_ref: TableRefWire::new(config, provider.table().identifier()), metadata_type: provider.metadata_type().as_str().to_string(), }; return encode_blob(buf, &node); @@ -360,18 +355,21 @@ fn single_input( mod tests { use std::collections::BTreeMap; - use crate::bridge::TAG_ICEBERG; + use crate::bridge::{CatalogConfigWire, TAG_ICEBERG}; use super::*; - fn sample_catalog() -> CatalogConfigWire { - CatalogConfigWire { - r#type: "rest".to_string(), - name: "rest".to_string(), - props: BTreeMap::from([ - ("uri".to_string(), "http://localhost:8181".to_string()), - ("warehouse".to_string(), "s3://bucket/wh".to_string()), - ]), + fn sample_table_ref() -> TableRefWire { + TableRefWire { + catalog: CatalogConfigWire { + r#type: "rest".to_string(), + name: "rest".to_string(), + props: BTreeMap::from([ + ("uri".to_string(), "http://localhost:8181".to_string()), + ("warehouse".to_string(), "s3://bucket/wh".to_string()), + ]), + }, + table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), } } @@ -385,8 +383,7 @@ mod tests { #[test] fn scan_node_roundtrips() { let node = IcebergPhysicalNode::Scan { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), snapshot_id: Some(42), projection: Some(vec!["a".to_string(), "b".to_string()]), limit: Some(10), @@ -401,8 +398,7 @@ mod tests { use iceberg::spec::Datum; let node = IcebergPhysicalNode::Scan { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), snapshot_id: None, projection: None, limit: None, @@ -422,8 +418,7 @@ mod tests { .and(Reference::new("b").is_null()) .or(Reference::new("c").is_in([Datum::string("x"), Datum::string("y")])); let node = IcebergPhysicalNode::Scan { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), snapshot_id: None, projection: None, limit: None, @@ -439,8 +434,7 @@ mod tests { // `predicates` is `#[serde(default)]`: a payload missing the key still decodes. let node = IcebergPhysicalNode::Scan { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), snapshot_id: Some(7), projection: None, limit: None, @@ -504,8 +498,7 @@ mod tests { #[test] fn metadata_node_roundtrips() { let node = IcebergPhysicalNode::Metadata { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), metadata_type: "snapshots".to_string(), }; assert_eq!(node, roundtrip(&node)); @@ -514,21 +507,39 @@ mod tests { #[test] fn write_node_roundtrips() { let node = IcebergPhysicalNode::Write { - catalog: sample_catalog(), - table: TableIdent::from_strs(["ns", "tbl"]).unwrap(), + table_ref: sample_table_ref(), }; assert_eq!(node, roundtrip(&node)); } #[test] fn commit_node_roundtrips() { + // Multi-level namespace, so the ident round-trip is exercised beyond + // the single-level `ns.tbl` the other tests use. let node = IcebergPhysicalNode::Commit { - catalog: sample_catalog(), - table: TableIdent::from_strs(["a", "b", "tbl"]).unwrap(), + table_ref: TableRefWire { + table: TableIdent::from_strs(["a", "b", "tbl"]).unwrap(), + ..sample_table_ref() + }, }; assert_eq!(node, roundtrip(&node)); } + #[test] + fn table_ref_flattens_to_inline_catalog_and_table_keys() { + // Wire compat: `TableRefWire` must serialize as inline `catalog` and + // `table` keys, exactly as when the variants spelled the two fields + // out — never nested under a `table_ref` object. + let node = IcebergPhysicalNode::Write { + table_ref: sample_table_ref(), + }; + let value = serde_json::to_value(&node).unwrap(); + let obj = value["Write"].as_object().unwrap(); + assert!(obj.contains_key("catalog"), "{value}"); + assert!(obj.contains_key("table"), "{value}"); + assert!(!obj.contains_key("table_ref"), "{value}"); + } + #[test] fn non_iceberg_node_roundtrips_through_inner_codec() { use ballista_core::execution_plans::ShuffleWriterExec; From 3e8dd1383de9026eec7abcc50638b46bafb6b0e2 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 1 Aug 2026 21:40:59 -0400 Subject: [PATCH 4/7] make tests all use assert_batches_eq --- .../iceberg/tests/distributed_read_write.rs | 253 +++++++++--------- 1 file changed, 123 insertions(+), 130 deletions(-) diff --git a/integrations/iceberg/tests/distributed_read_write.rs b/integrations/iceberg/tests/distributed_read_write.rs index eabdf10fc4..412fd3e365 100644 --- a/integrations/iceberg/tests/distributed_read_write.rs +++ b/integrations/iceberg/tests/distributed_read_write.rs @@ -35,8 +35,8 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use arrow::array::{AsArray, Int64Array, RecordBatch}; -use arrow::datatypes::Int32Type; +use arrow::array::RecordBatch; +use ballista::datafusion::assert_batches_eq; use ballista::datafusion::execution::{SessionState, SessionStateBuilder}; use ballista::datafusion::prelude::{SessionConfig, SessionContext}; use ballista::prelude::{SessionConfigExt, SessionContextExt}; @@ -93,42 +93,6 @@ async fn run_sql(ctx: &SessionContext, sql: &str) -> Vec { .expect("run sql") } -/// Extracts the single `i64` value of the first column (a COUNT result). -fn single_i64(batches: &[RecordBatch]) -> i64 { - batches - .iter() - .find_map(|b| { - b.column(0) - .as_any() - .downcast_ref::() - .map(|a| a.value(0)) - }) - .expect("i64 column") -} - -/// Flattens the first column of every batch into a `Vec`. -fn i32_values(batches: &[RecordBatch]) -> Vec { - batches - .iter() - .flat_map(|b| b.column(0).as_primitive::().values().to_vec()) - .collect() -} - -/// Flattens a named string column across all batches, preserving nulls. -fn string_values(batches: &[RecordBatch], column: &str) -> Vec> { - batches - .iter() - .flat_map(|b| { - let idx = b.schema().index_of(column).expect("column present"); - b.column(idx) - .as_string::() - .iter() - .map(|v| v.map(str::to_string)) - .collect::>() - }) - .collect() -} - fn catalog_props() -> HashMap { let rest_uri = std::env::var("ICEBERG_REST_URI") .unwrap_or_else(|_| "http://localhost:8181".to_string()); @@ -210,18 +174,6 @@ async fn create_table( create_table_with(props, table_name, schema, None).await } -/// Column names of a result set, in order. -fn column_names(batches: &[RecordBatch]) -> Vec { - batches - .first() - .expect("at least one batch") - .schema() - .fields() - .iter() - .map(|f| f.name().clone()) - .collect() -} - /// Loads the table from the catalog and returns its current snapshot id. async fn current_snapshot_id( props: &HashMap, @@ -304,8 +256,10 @@ async fn distributed_insert_and_read() { // meaningful: it forces the registered provider through a full plan/scan // cycle while the table is still empty, so a provider that froze its table // metadata here would keep returning this empty snapshot forever. - let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM events").await); - assert_eq!(count, 0, "table is empty before the first insert"); + assert_batches_eq!( + ["+---+", "| n |", "+---+", "| 0 |", "+---+"], + &run_sql(&ctx, "SELECT count(*) AS n FROM events").await + ); run_sql( &ctx, @@ -313,11 +267,23 @@ async fn distributed_insert_and_read() { ) .await; - let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM events").await); - assert_eq!(count, 3, "expected 3 rows after distributed insert"); + assert_batches_eq!( + ["+---+", "| n |", "+---+", "| 3 |", "+---+"], + &run_sql(&ctx, "SELECT count(*) AS n FROM events").await + ); - let rows = run_sql(&ctx, "SELECT id, name FROM events ORDER BY id").await; - assert_eq!(i32_values(&rows), vec![1, 2, 3]); + assert_batches_eq!( + [ + "+----+-------+", + "| id | name |", + "+----+-------+", + "| 1 | alice |", + "| 2 | bob |", + "| 3 | carol |", + "+----+-------+", + ], + &run_sql(&ctx, "SELECT id, name FROM events ORDER BY id").await + ); // Insert again through the same registration and re-read. `scan` reloads // table metadata from the catalog every time, so each query plans against @@ -326,14 +292,26 @@ async fn distributed_insert_and_read() { // the first read would still report 3 rows here. run_sql(&ctx, "INSERT INTO events VALUES (4, 'dave'), (5, 'erin')").await; - let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM events").await); - assert_eq!(count, 5, "re-read must observe the interposed insert"); + // The re-read must observe the interposed insert: both the original and the + // newly inserted rows. + assert_batches_eq!( + ["+---+", "| n |", "+---+", "| 5 |", "+---+"], + &run_sql(&ctx, "SELECT count(*) AS n FROM events").await + ); - let rows = run_sql(&ctx, "SELECT id, name FROM events ORDER BY id").await; - assert_eq!( - i32_values(&rows), - vec![1, 2, 3, 4, 5], - "re-read returns both the original and the newly inserted rows" + assert_batches_eq!( + [ + "+----+-------+", + "| id | name |", + "+----+-------+", + "| 1 | alice |", + "| 2 | bob |", + "| 3 | carol |", + "| 4 | dave |", + "| 5 | erin |", + "+----+-------+", + ], + &run_sql(&ctx, "SELECT id, name FROM events ORDER BY id").await ); // Catalog-level registration: mount the whole Iceberg catalog and read the @@ -347,14 +325,14 @@ async fn distributed_insert_and_read() { ) .await .expect("register iceberg catalog"); - let count = single_i64( + assert_batches_eq!( + ["+---+", "| n |", "+---+", "| 5 |", "+---+"], &run_sql( &ctx, &format!("SELECT count(*) AS n FROM ice.ballista_it.{table_name}"), ) - .await, + .await ); - assert_eq!(count, 5, "catalog-qualified distributed read"); } /// Distributed correctness on a real multi-executor cluster, writing a @@ -495,31 +473,35 @@ async fn parallel_multi_executor_insert_commits_all_rows() { "partitioned write should produce multiple data files, got {data_files}" ); - // (3) Every row landed exactly once. - let count = single_i64(&run_sql(&ctx, "SELECT count(*) AS n FROM target").await); - assert_eq!(count as i32, TOTAL_ROWS, "row count after parallel insert"); + // (3) Every row landed exactly once: the exact id set 1..=TOTAL_ROWS, no + // lost or duplicated rows. + assert_batches_eq!( + ["+----+", "| n |", "+----+", "| 12 |", "+----+"], + &run_sql(&ctx, "SELECT count(*) AS n FROM target").await + ); - let ids = i32_values(&run_sql(&ctx, "SELECT id FROM target ORDER BY id").await); - assert_eq!( - ids, - (1..=TOTAL_ROWS).collect::>(), - "exact id set after parallel insert (no lost or duplicated rows)" + assert_batches_eq!( + [ + "+----+", "| id |", "+----+", "| 1 |", "| 2 |", "| 3 |", "| 4 |", + "| 5 |", "| 6 |", "| 7 |", "| 8 |", "| 9 |", "| 10 |", "| 11 |", + "| 12 |", "+----+", + ], + &run_sql(&ctx, "SELECT id FROM target ORDER BY id").await ); // (4) Predicate pushdown survives serialization: a WHERE clause is pushed into // the distributed scan (and re-applied above it), and the result is correct. let half = TOTAL_ROWS / 2; - let filtered_ids = i32_values( + assert_batches_eq!( + [ + "+----+", "| id |", "+----+", "| 1 |", "| 2 |", "| 3 |", "| 4 |", + "| 5 |", "| 6 |", "+----+", + ], &run_sql( &ctx, &format!("SELECT id FROM target WHERE id <= {half} ORDER BY id"), ) - .await, - ); - assert_eq!( - filtered_ids, - (1..=half).collect::>(), - "predicate-filtered distributed read" + .await ); } @@ -621,14 +603,23 @@ async fn distributed_time_travel_pins_snapshot_schema() { .await; let snapshot_2 = current_snapshot_id(&props, &namespace, &table_name).await; - // The current distributed read sees all six rows under the evolved schema. - let current = run_sql(&ctx, "SELECT * FROM events_v2 ORDER BY id").await; - assert_eq!( - column_names(¤t), - vec!["id", "name", "email"], - "phase 2: current read exposes the evolved schema" + // Phase 2: the current distributed read sees all six rows under the evolved + // schema (the header row asserts the schema; rows 1-3 predate `email`). + assert_batches_eq!( + [ + "+----+-------+--------------+", + "| id | name | email |", + "+----+-------+--------------+", + "| 1 | alice | |", + "| 2 | bob | |", + "| 3 | carol | |", + "| 4 | dave | dave@x.test |", + "| 5 | erin | erin@x.test |", + "| 6 | frank | frank@x.test |", + "+----+-------+--------------+", + ], + &run_sql(&ctx, "SELECT * FROM events_v2 ORDER BY id").await ); - assert_eq!(i32_values(¤t), vec![1, 2, 3, 4, 5, 6]); // Phase 3 — pin to snapshot 1, which has no `email` while the table does. // Must run before the drop below, or the two schemas match again and the @@ -647,17 +638,19 @@ async fn distributed_time_travel_pins_snapshot_schema() { ctx.register_table("events_v1", Arc::new(pinned_v1)) .expect("register provider pinned to snapshot 1"); - // The pinned read returns that snapshot's rows under that snapshot's schema. - let pinned = run_sql(&ctx, "SELECT * FROM events_v1 ORDER BY id").await; - assert_eq!( - column_names(&pinned), - vec!["id", "name"], - "phase 3: pinned read exposes the schema in effect at that snapshot" - ); - assert_eq!( - i32_values(&pinned), - vec![1, 2, 3], - "phase 3: exact historical row set from the pinned snapshot" + // The pinned read returns that snapshot's exact historical rows under the + // schema in effect at that snapshot — no `email` column. + assert_batches_eq!( + [ + "+----+-------+", + "| id | name |", + "+----+-------+", + "| 1 | alice |", + "| 2 | bob |", + "| 3 | carol |", + "+----+-------+", + ], + &run_sql(&ctx, "SELECT * FROM events_v1 ORDER BY id").await ); // Phase 4 — drop `name`, leaving {id, email}. Snapshot 2 keeps referencing @@ -698,13 +691,22 @@ async fn distributed_time_travel_pins_snapshot_schema() { ctx.register_table("events_v3", Arc::new(dropped)) .expect("register provider after drop"); - let current = run_sql(&ctx, "SELECT * FROM events_v3 ORDER BY id").await; - assert_eq!( - column_names(¤t), - vec!["id", "email"], - "phase 4: current read reflects the dropped column" + // The current read reflects the dropped column: `name` is gone. + assert_batches_eq!( + [ + "+----+--------------+", + "| id | email |", + "+----+--------------+", + "| 1 | |", + "| 2 | |", + "| 3 | |", + "| 4 | dave@x.test |", + "| 5 | erin@x.test |", + "| 6 | frank@x.test |", + "+----+--------------+", + ], + &run_sql(&ctx, "SELECT * FROM events_v3 ORDER BY id").await ); - assert_eq!(i32_values(¤t), vec![1, 2, 3, 4, 5, 6]); // Phase 5 — pin to snapshot 2, which still has the `name` the table just // lost. An executor using the current schema has no `name` to project, so @@ -723,31 +725,22 @@ async fn distributed_time_travel_pins_snapshot_schema() { ctx.register_table("events_v2_pinned", Arc::new(pinned_v2)) .expect("register provider pinned to snapshot 2"); - let pinned = run_sql(&ctx, "SELECT * FROM events_v2_pinned ORDER BY id").await; - assert_eq!( - column_names(&pinned), - vec!["id", "name", "email"], - "phase 5: pinned read exposes the column that existed at that snapshot" - ); - assert_eq!(i32_values(&pinned), vec![1, 2, 3, 4, 5, 6]); - assert_eq!( - string_values(&pinned, "name"), - ["alice", "bob", "carol", "dave", "erin", "frank"] - .map(|s| Some(s.to_string())) - .to_vec(), - "phase 5: the dropped column's values still read back from the pinned \ - snapshot" - ); - assert_eq!( - string_values(&pinned, "email"), - vec![ - None, - None, - None, - Some("dave@x.test".to_string()), - Some("erin@x.test".to_string()), - Some("frank@x.test".to_string()), + // The pinned read exposes the `name` column that existed at that snapshot, + // with the dropped column's values still reading back — and rows written + // before `email` existed read back null. + assert_batches_eq!( + [ + "+----+-------+--------------+", + "| id | name | email |", + "+----+-------+--------------+", + "| 1 | alice | |", + "| 2 | bob | |", + "| 3 | carol | |", + "| 4 | dave | dave@x.test |", + "| 5 | erin | erin@x.test |", + "| 6 | frank | frank@x.test |", + "+----+-------+--------------+", ], - "phase 5: rows written before `email` existed read back null" + &run_sql(&ctx, "SELECT * FROM events_v2_pinned ORDER BY id").await ); } From 15489b8bb1babe17d6a161bd7fd2032ba44cb89d Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 2 Aug 2026 22:52:55 -0400 Subject: [PATCH 5/7] fix doc --- integrations/iceberg/src/logical_codec.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integrations/iceberg/src/logical_codec.rs b/integrations/iceberg/src/logical_codec.rs index 073943093f..0958125858 100644 --- a/integrations/iceberg/src/logical_codec.rs +++ b/integrations/iceberg/src/logical_codec.rs @@ -16,7 +16,8 @@ // under the License. //! Logical extension codec that serializes the catalog-backed -//! [`IcebergTableProvider`] (its [`IcebergCatalogConfig`] + table identifier) so +//! [`IcebergTableProvider`] (its [`IcebergCatalogConfig`](crate::IcebergCatalogConfig) +//! + table identifier) so //! that the Ballista scheduler can rebuild the provider from a logical plan and //! perform physical planning (including `insert_into`) for Iceberg tables. //! From 0e0138ad6e83a5a2dac9d6cb420ab92aebf52591 Mon Sep 17 00:00:00 2001 From: Noah Date: Tue, 4 Aug 2026 00:22:29 -0400 Subject: [PATCH 6/7] use testcontainers not docker-compose --- .github/workflows/rust.yml | 28 +-- Cargo.lock | 2 + integrations/iceberg/Cargo.toml | 8 +- integrations/iceberg/dev/docker-compose.yaml | 112 ---------- .../examples/standalone-iceberg-write.rs | 33 +-- .../iceberg/tests/distributed_read_write.rs | 92 +++----- integrations/iceberg/tests/fixture/mod.rs | 200 ++++++++++++++++++ 7 files changed, 255 insertions(+), 220 deletions(-) delete mode 100644 integrations/iceberg/dev/docker-compose.yaml create mode 100644 integrations/iceberg/tests/fixture/mod.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index cc332ef48e..d71f8f62af 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -160,10 +160,10 @@ jobs: iceberg-test: name: test iceberg integration # Deliberately not in the amd64/rust container the other linux jobs use: - # this job drives docker compose to bring up an Iceberg REST catalog and - # MinIO, and the test talks to them over the ports they publish on the - # runner. Running in a container would mean docker-in-docker plus a - # different hostname for those endpoints. + # the tests start an Iceberg REST catalog and MinIO via testcontainers and + # talk to them over the ports they publish on the runner. Running in a + # container would mean docker-in-docker plus a different hostname for those + # endpoints. runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 @@ -181,26 +181,12 @@ jobs: - name: Configure rust runtime env uses: ./.github/actions/setup-rust-runtime - uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #2.9.1 - - name: Start Iceberg REST catalog and MinIO - run: | - docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait - name: Run iceberg-ballista tests - # The end-to-end suite is behind `integration-tests` precisely because it - # needs the fixture above; without the feature this only runs unit tests. + # Each test brings up its own catalog + MinIO with testcontainers, which + # is why the suite is behind `integration-tests`: without the feature + # this only runs unit tests and needs no docker. run: | cargo test --profile ci -p iceberg-ballista --features integration-tests --locked - - name: Stop Iceberg REST catalog and MinIO - if: always() - run: | - docker compose -f integrations/iceberg/dev/docker-compose.yaml logs --no-color > /tmp/iceberg-fixture.log || true - docker compose -f integrations/iceberg/dev/docker-compose.yaml down -v --remove-orphans --timeout 0 - - name: Upload fixture logs - if: failure() - uses: actions/upload-artifact@v7 - with: - name: iceberg-fixture-logs - path: /tmp/iceberg-fixture.log - if-no-files-found: ignore macos-test: name: macos test diff --git a/Cargo.lock b/Cargo.lock index 47beaecbfd..fead1661bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4527,6 +4527,7 @@ dependencies = [ "log", "serde", "serde_json", + "testcontainers-modules", "tokio", ] @@ -8706,6 +8707,7 @@ dependencies = [ "memchr", "parse-display", "pin-project-lite", + "reqwest 0.13.4", "serde", "serde_json", "serde_with", diff --git a/integrations/iceberg/Cargo.toml b/integrations/iceberg/Cargo.toml index 6b2ae82c00..07afdd38a1 100644 --- a/integrations/iceberg/Cargo.toml +++ b/integrations/iceberg/Cargo.toml @@ -63,11 +63,15 @@ ballista-scheduler = { path = "../../ballista/scheduler", version = "54.0.0", de env_logger = { workspace = true } iceberg-catalog-rest = { workspace = true } log = { workspace = true } +# Starts the Iceberg REST catalog + MinIO the integration tests and the +# standalone example run against. Not optional: cargo has no optional +# dev-dependencies, so this is compiled for any `cargo test` on this crate. +testcontainers-modules = { version = "0.15", features = ["minio", "http_wait"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [features] default = [] -# The end-to-end suite needs the Iceberg REST catalog + MinIO fixture in -# `dev/docker-compose.yaml`, so it is off by default and opted into explicitly: +# The end-to-end suite starts an Iceberg REST catalog + MinIO in docker, so it +# is off by default and opted into explicitly: # cargo test -p iceberg-ballista --features integration-tests integration-tests = [] diff --git a/integrations/iceberg/dev/docker-compose.yaml b/integrations/iceberg/dev/docker-compose.yaml deleted file mode 100644 index e1f62d548a..0000000000 --- a/integrations/iceberg/dev/docker-compose.yaml +++ /dev/null @@ -1,112 +0,0 @@ -# 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. - -# Fixture for the iceberg-ballista integration tests and the -# standalone-iceberg-write example: an Iceberg REST catalog backed by MinIO. -# Trimmed down from the iceberg-rust dev fixture — only the REST catalog is -# exercised here, so the HMS / Glue / GCS / Spark services are omitted. -# -# docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait -# cargo test -p iceberg-ballista --features integration-tests -# docker compose -f integrations/iceberg/dev/docker-compose.yaml down -v -# -# Note: `docker compose restart rest` on its own drops the REST -> MinIO network -# alias and later table creations fail with an unknown-host error. Recreate the -# whole set instead: `... up -d --force-recreate --wait`. - -networks: - iceberg_test: - -services: - # S3-compatible storage for table data and metadata. - minio: - image: minio/minio:RELEASE.2025-05-24T17-08-30Z - environment: - - MINIO_ROOT_USER=admin - - MINIO_ROOT_PASSWORD=password - - MINIO_DOMAIN=minio - hostname: minio - networks: - iceberg_test: - # Alias for virtual-hosted style bucket access. - aliases: - - icebergdata.minio - ports: - - "9000:9000" - - "9001:9001" - command: ["server", "/data", "--console-address", ":9001"] - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 5 - - # Creates the warehouse bucket the REST catalog writes to. - mc: - depends_on: - minio: - condition: service_healthy - image: minio/mc:RELEASE.2025-05-21T01-59-54Z - environment: - - AWS_ACCESS_KEY_ID=admin - - AWS_SECRET_ACCESS_KEY=password - - AWS_REGION=us-east-1 - entrypoint: > - /bin/sh -c " - /usr/bin/mc alias set minio http://minio:9000 admin password; - /usr/bin/mc mb --ignore-existing minio/icebergdata; - /usr/bin/mc policy set public minio/icebergdata; - touch /tmp/buckets_ready; - echo 'Buckets created successfully'; - tail -f /dev/null - " - networks: - iceberg_test: - # Without this, `up --wait` returns as soon as the container is *running* — - # which is before `mc mb` has finished — and the first create-table races - # the bucket into existence. - healthcheck: - test: ["CMD-SHELL", "[ -f /tmp/buckets_ready ]"] - interval: 2s - timeout: 2s - retries: 30 - - # Iceberg REST catalog (JDBC catalog over in-memory SQLite). - rest: - image: apache/iceberg-rest-fixture:1.10.1 - environment: - - AWS_ACCESS_KEY_ID=admin - - AWS_SECRET_ACCESS_KEY=password - - AWS_REGION=us-east-1 - - CATALOG_CATALOG__IMPL=org.apache.iceberg.jdbc.JdbcCatalog - - CATALOG_URI=jdbc:sqlite:file:/tmp/iceberg_rest_mode=memory - - CATALOG_WAREHOUSE=s3://icebergdata/demo - - CATALOG_IO__IMPL=org.apache.iceberg.aws.s3.S3FileIO - - CATALOG_S3_ENDPOINT=http://minio:9000 - depends_on: - minio: - condition: service_healthy - networks: - iceberg_test: - ports: - - "8181:8181" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8181/v1/config"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 10s diff --git a/integrations/iceberg/examples/standalone-iceberg-write.rs b/integrations/iceberg/examples/standalone-iceberg-write.rs index 13b355f7bd..c240ee275f 100644 --- a/integrations/iceberg/examples/standalone-iceberg-write.rs +++ b/integrations/iceberg/examples/standalone-iceberg-write.rs @@ -20,16 +20,18 @@ //! Demonstrates distributed reads and writes against an Apache Iceberg table //! from a standalone Ballista cluster. //! -//! This example requires a running Iceberg REST catalog and MinIO. Bring up the -//! docker fixture from the workspace root: +//! The example starts its own Iceberg REST catalog and MinIO with +//! testcontainers, so all it needs is a running docker daemon: //! //! ```bash -//! docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait //! cargo run -p iceberg-ballista --example standalone-iceberg-write //! ``` //! -//! Endpoints can be overridden with the `ICEBERG_REST_URI` and -//! `ICEBERG_S3_ENDPOINT` environment variables. +//! The containers are removed when the example exits. + +// Shared with the integration tests rather than duplicated. +#[path = "../tests/fixture/mod.rs"] +mod fixture; use std::collections::HashMap; use std::sync::Arc; @@ -46,22 +48,6 @@ use iceberg_ballista::{ use iceberg_catalog_rest::RestCatalogBuilder; use iceberg_storage_opendal::OpenDalStorageFactory; -/// Catalog + storage properties for the REST catalog and its MinIO storage. -fn catalog_props() -> HashMap { - let rest_uri = std::env::var("ICEBERG_REST_URI") - .unwrap_or_else(|_| "http://localhost:8181".to_string()); - let s3_endpoint = std::env::var("ICEBERG_S3_ENDPOINT") - .unwrap_or_else(|_| "http://localhost:9000".to_string()); - HashMap::from([ - ("uri".to_string(), rest_uri), - ("s3.endpoint".to_string(), s3_endpoint), - ("s3.access-key-id".to_string(), "admin".to_string()), - ("s3.secret-access-key".to_string(), "password".to_string()), - ("s3.region".to_string(), "us-east-1".to_string()), - ("s3.path-style-access".to_string(), "true".to_string()), - ]) -} - /// Creates the demo namespace and table in the catalog if they do not exist. async fn ensure_table( props: &HashMap, @@ -122,7 +108,10 @@ async fn main() -> Result<()> { .filter_level(log::LevelFilter::Info) .try_init(); - let props = catalog_props(); + // Iceberg REST catalog + MinIO in docker, removed when this drops. + println!("== starting Iceberg REST catalog and MinIO =="); + let catalog_fixture = fixture::IcebergFixture::start().await; + let props = catalog_fixture.props(); // Make sure the target table exists in the catalog. let (namespace, table) = ensure_table(&props).await?; diff --git a/integrations/iceberg/tests/distributed_read_write.rs b/integrations/iceberg/tests/distributed_read_write.rs index 412fd3e365..3ea9009a8f 100644 --- a/integrations/iceberg/tests/distributed_read_write.rs +++ b/integrations/iceberg/tests/distributed_read_write.rs @@ -17,23 +17,22 @@ //! End-to-end distributed Iceberg write/read test. //! -//! This runs against the docker fixture in `integrations/iceberg/dev` (an -//! Iceberg REST catalog + MinIO), so it is behind the `integration-tests` -//! feature and is not part of a plain `cargo test`: +//! Each test starts its own Iceberg REST catalog + MinIO with testcontainers +//! (see [`fixture`]), so this needs a working docker daemon. That is why it +//! sits behind the `integration-tests` feature and is not part of a plain +//! `cargo test`: //! //! ```bash -//! docker compose -f integrations/iceberg/dev/docker-compose.yaml up -d --wait //! cargo test -p iceberg-ballista --features integration-tests --test distributed_read_write //! ``` -//! -//! The endpoints can be overridden with the `ICEBERG_REST_URI` and -//! `ICEBERG_S3_ENDPOINT` environment variables. #![cfg(feature = "integration-tests")] +mod fixture; + use std::collections::HashMap; -use std::sync::{Arc, LazyLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::Arc; +use std::time::Duration; use arrow::array::RecordBatch; use ballista::datafusion::assert_batches_eq; @@ -56,24 +55,8 @@ use iceberg_ballista::{ use iceberg_catalog_rest::RestCatalogBuilder; use iceberg_datafusion::IcebergTableProvider; use iceberg_storage_opendal::OpenDalStorageFactory; -use tokio::sync::Mutex; - -/// Serializes the catalog-mutating tests in this binary. The REST fixture is -/// backed by in-memory SQLite, which rejects concurrent commits with -/// `SQLITE_BUSY`. Each test still exercises parallelism *internally* (multiple -/// write tasks / executors); this only stops the two test cases from committing -/// to the catalog at the same time, so the suite is robust under a parallel test -/// harness (`cargo test`, `nextest`) without relying on `--test-threads=1`. -static CATALOG_GUARD: LazyLock> = LazyLock::new(|| Mutex::new(())); - -/// Table name unique per run, so reruns don't collide in the shared catalog. -fn unique_table_name(prefix: &str) -> String { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); - format!("{prefix}_{millis}") -} + +use crate::fixture::IcebergFixture; /// Session state with the Iceberg codecs installed. fn iceberg_session_state(config: SessionConfig) -> SessionState { @@ -93,21 +76,6 @@ async fn run_sql(ctx: &SessionContext, sql: &str) -> Vec { .expect("run sql") } -fn catalog_props() -> HashMap { - let rest_uri = std::env::var("ICEBERG_REST_URI") - .unwrap_or_else(|_| "http://localhost:8181".to_string()); - let s3_endpoint = std::env::var("ICEBERG_S3_ENDPOINT") - .unwrap_or_else(|_| "http://localhost:9000".to_string()); - HashMap::from([ - ("uri".to_string(), rest_uri), - ("s3.endpoint".to_string(), s3_endpoint), - ("s3.access-key-id".to_string(), "admin".to_string()), - ("s3.secret-access-key".to_string(), "password".to_string()), - ("s3.region".to_string(), "us-east-1".to_string()), - ("s3.path-style-access".to_string(), "true".to_string()), - ]) -} - async fn build_rest_catalog(props: &HashMap) -> impl Catalog + use<> { RestCatalogBuilder::default() .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 { @@ -118,21 +86,19 @@ async fn build_rest_catalog(props: &HashMap) -> impl Catalog + u .expect("build rest catalog") } -/// Ensures the shared test namespace exists, tolerating a concurrent creator -/// (tests run in parallel and share this namespace). -async fn ensure_namespace(catalog: &impl Catalog) -> NamespaceIdent { +/// Creates the test namespace. Each test owns its catalog, so this never races +/// another creator. +async fn create_namespace(catalog: &impl Catalog) -> NamespaceIdent { let namespace = NamespaceIdent::new("ballista_it".to_string()); - if !catalog.namespace_exists(&namespace).await.unwrap() - && let Err(e) = catalog.create_namespace(&namespace, HashMap::new()).await - && !catalog.namespace_exists(&namespace).await.unwrap() - { - panic!("create namespace: {e}"); - } + catalog + .create_namespace(&namespace, HashMap::new()) + .await + .expect("create namespace"); namespace } -/// Creates `table_name` with `schema` (optionally partitioned) in the shared -/// test namespace and returns that namespace. +/// Creates `table_name` with `schema` (optionally partitioned) in the test +/// namespace and returns that namespace. async fn create_table_with( props: &HashMap, table_name: &str, @@ -140,7 +106,7 @@ async fn create_table_with( partition_spec: Option, ) -> NamespaceIdent { let catalog = build_rest_catalog(props).await; - let namespace = ensure_namespace(&catalog).await; + let namespace = create_namespace(&catalog).await; let builder = TableCreation::builder() .name(table_name.to_string()) @@ -226,10 +192,10 @@ async fn create_partitioned_table( #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn distributed_insert_and_read() { let _ = env_logger::builder().is_test(true).try_init(); - let _catalog_guard = CATALOG_GUARD.lock().await; - let props = catalog_props(); - let table_name = unique_table_name("events"); + let fixture = IcebergFixture::start().await; + let props = fixture.props(); + let table_name = "events".to_string(); let namespace = create_table(&props, &table_name).await; let state = iceberg_session_state( @@ -354,7 +320,6 @@ async fn distributed_insert_and_read() { #[tokio::test(flavor = "multi_thread", worker_threads = 8)] async fn parallel_multi_executor_insert_commits_all_rows() { let _ = env_logger::builder().is_test(true).try_init(); - let _catalog_guard = CATALOG_GUARD.lock().await; const N_EXECUTORS: usize = 2; const SLOTS_PER_EXECUTOR: usize = 2; @@ -363,8 +328,9 @@ async fn parallel_multi_executor_insert_commits_all_rows() { // REGIONS.len() * 3. const TOTAL_ROWS: i32 = 12; - let props = catalog_props(); - let table_name = unique_table_name("parallel_events"); + let fixture = IcebergFixture::start().await; + let props = fixture.props(); + let table_name = "parallel_events".to_string(); let namespace = create_partitioned_table(&props, &table_name).await; // --- Bring up one scheduler + N executors in-process (real multi-executor) --- @@ -531,10 +497,10 @@ async fn parallel_multi_executor_insert_commits_all_rows() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn distributed_time_travel_pins_snapshot_schema() { let _ = env_logger::builder().is_test(true).try_init(); - let _catalog_guard = CATALOG_GUARD.lock().await; - let props = catalog_props(); - let table_name = unique_table_name("timetravel"); + let fixture = IcebergFixture::start().await; + let props = fixture.props(); + let table_name = "timetravel".to_string(); let namespace = create_table(&props, &table_name).await; let state = iceberg_session_state( diff --git a/integrations/iceberg/tests/fixture/mod.rs b/integrations/iceberg/tests/fixture/mod.rs new file mode 100644 index 0000000000..ec274f1795 --- /dev/null +++ b/integrations/iceberg/tests/fixture/mod.rs @@ -0,0 +1,200 @@ +// 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. + +//! Docker fixture for the Iceberg integration tests and the +//! `standalone-iceberg-write` example: an Iceberg REST catalog backed by MinIO. +//! +//! Each [`IcebergFixture`] is a private catalog on its own docker network, so +//! tests holding one share no namespace, table name, or catalog state. The +//! example includes this module by path, so it must not depend on anything +//! test-only. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use testcontainers_modules::minio::MinIO; +use testcontainers_modules::testcontainers::core::wait::HttpWaitStrategy; +use testcontainers_modules::testcontainers::core::{ + CmdWaitFor, ExecCommand, IntoContainerPort, WaitFor, +}; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use testcontainers_modules::testcontainers::{ContainerAsync, GenericImage, ImageExt}; + +const MINIO_TAG: &str = "RELEASE.2025-05-24T17-08-30Z"; +const REST_IMAGE: &str = "apache/iceberg-rest-fixture"; +const REST_TAG: &str = "1.10.1"; + +const MINIO_PORT: u16 = 9000; +const REST_PORT: u16 = 8181; + +const BUCKET: &str = "icebergdata"; +const ACCESS_KEY: &str = "admin"; +const SECRET_KEY: &str = "password"; +const REGION: &str = "us-east-1"; + +/// A running Iceberg REST catalog + MinIO pair, removed on drop. +pub struct IcebergFixture { + props: HashMap, + _minio: ContainerAsync, + _rest: ContainerAsync, +} + +impl IcebergFixture { + /// Starts MinIO and an Iceberg REST catalog in front of it, panicking on any + /// docker failure. + pub async fn start() -> Self { + // Container and network names are global to the docker daemon, and + // several fixtures run at once. + let suffix = unique_suffix(); + let network = format!("iceberg-ballista-{suffix}"); + // Also the hostname the REST catalog reaches MinIO on. + let minio_host = format!("iceberg-ballista-minio-{suffix}"); + + let minio = MinIO::default() + .with_tag(MINIO_TAG) + .with_env_var("MINIO_ROOT_USER", ACCESS_KEY) + .with_env_var("MINIO_ROOT_PASSWORD", SECRET_KEY) + .with_network(&network) + .with_container_name(&minio_host) + .with_startup_timeout(Duration::from_secs(120)) + .start() + .await + .expect("start minio"); + + create_bucket(&minio).await; + + let rest = GenericImage::new(REST_IMAGE, REST_TAG) + .with_exposed_port(REST_PORT.tcp()) + .with_wait_for(WaitFor::http( + HttpWaitStrategy::new("/v1/config") + .with_port(REST_PORT.tcp()) + .with_expected_status_code(200u16), + )) + .with_network(&network) + .with_env_var("AWS_ACCESS_KEY_ID", ACCESS_KEY) + .with_env_var("AWS_SECRET_ACCESS_KEY", SECRET_KEY) + .with_env_var("AWS_REGION", REGION) + .with_env_var( + "CATALOG_CATALOG__IMPL", + "org.apache.iceberg.jdbc.JdbcCatalog", + ) + .with_env_var( + "CATALOG_URI", + "jdbc:sqlite:file:/tmp/iceberg_rest_mode=memory", + ) + .with_env_var("CATALOG_WAREHOUSE", format!("s3://{BUCKET}/demo")) + .with_env_var("CATALOG_IO__IMPL", "org.apache.iceberg.aws.s3.S3FileIO") + .with_env_var( + "CATALOG_S3_ENDPOINT", + format!("http://{minio_host}:{MINIO_PORT}"), + ) + // Virtual-hosted style would need a `.` DNS alias, + // which testcontainers cannot add. + .with_env_var("CATALOG_S3_PATH__STYLE__ACCESS", "true") + .with_startup_timeout(Duration::from_secs(120)) + .start() + .await + .expect("start iceberg rest catalog"); + + let props = catalog_props( + endpoint(&rest, REST_PORT).await, + endpoint(&minio, MINIO_PORT).await, + ); + + Self { + props, + _minio: minio, + _rest: rest, + } + } + + /// Catalog + storage properties addressing this fixture. + pub fn props(&self) -> HashMap { + self.props.clone() + } +} + +/// Creates the warehouse bucket with the `mc` the MinIO image ships. +async fn create_bucket(minio: &ContainerAsync) { + // The image preconfigures a `local` alias without credentials. + exec( + minio, + [ + "mc", + "alias", + "set", + "local", + &format!("http://localhost:{MINIO_PORT}"), + ACCESS_KEY, + SECRET_KEY, + ], + ) + .await; + exec( + minio, + ["mc", "mb", "--ignore-existing", &format!("local/{BUCKET}")], + ) + .await; +} + +/// Runs `cmd` in the container and waits for it to exit successfully. +async fn exec<'a>( + container: &ContainerAsync, + cmd: impl IntoIterator, +) { + let cmd: Vec = cmd.into_iter().map(str::to_string).collect(); + let description = cmd.join(" "); + container + .exec(ExecCommand::new(cmd).with_cmd_ready_condition(CmdWaitFor::exit_code(0))) + .await + .unwrap_or_else(|e| panic!("`{description}` in minio container: {e}")); +} + +/// `http://:` for a container port, as seen from this +/// process. +async fn endpoint( + container: &ContainerAsync, + port: u16, +) -> String { + let host = container.get_host().await.expect("container host"); + let port = container + .get_host_port_ipv4(port.tcp()) + .await + .expect("published port"); + format!("http://{host}:{port}") +} + +fn catalog_props(rest_uri: String, s3_endpoint: String) -> HashMap { + HashMap::from([ + ("uri".to_string(), rest_uri), + ("s3.endpoint".to_string(), s3_endpoint), + ("s3.access-key-id".to_string(), ACCESS_KEY.to_string()), + ("s3.secret-access-key".to_string(), SECRET_KEY.to_string()), + ("s3.region".to_string(), REGION.to_string()), + ("s3.path-style-access".to_string(), "true".to_string()), + ]) +} + +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_nanos(); + format!("{}-{}", std::process::id(), nanos % 1_000_000_000) +} From 3070ab2a79d5d9866e1cfdb2d69d4a56e1a60f71 Mon Sep 17 00:00:00 2001 From: Noah Date: Mon, 10 Aug 2026 00:03:39 -0400 Subject: [PATCH 7/7] better clustered deployment documentation + codec fallback --- Cargo.lock | 19 +- integrations/iceberg/Cargo.toml | 20 +- .../iceberg/examples/cluster-iceberg-write.rs | 289 ++++++++++++++ .../examples/standalone-iceberg-write.rs | 65 +--- integrations/iceberg/src/lib.rs | 14 +- integrations/iceberg/src/physical_codec.rs | 6 +- .../iceberg/tests/distributed_read_write.rs | 358 +++++++++++++----- integrations/iceberg/tests/fixture/mod.rs | 60 ++- 8 files changed, 652 insertions(+), 179 deletions(-) create mode 100644 integrations/iceberg/examples/cluster-iceberg-write.rs diff --git a/Cargo.lock b/Cargo.lock index fead1661bc..f84227a20e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4454,7 +4454,7 @@ dependencies = [ [[package]] name = "iceberg" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "aes-gcm", "anyhow", @@ -4529,12 +4529,13 @@ dependencies = [ "serde_json", "testcontainers-modules", "tokio", + "tokio-stream", ] [[package]] name = "iceberg-catalog-glue" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "anyhow", "async-trait", @@ -4549,7 +4550,7 @@ dependencies = [ [[package]] name = "iceberg-catalog-hms" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "anyhow", "async-trait", @@ -4571,7 +4572,7 @@ dependencies = [ [[package]] name = "iceberg-catalog-loader" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "async-trait", "iceberg", @@ -4586,7 +4587,7 @@ dependencies = [ [[package]] name = "iceberg-catalog-rest" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "async-trait", "chrono", @@ -4605,7 +4606,7 @@ dependencies = [ [[package]] name = "iceberg-catalog-s3tables" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "anyhow", "async-trait", @@ -4618,7 +4619,7 @@ dependencies = [ [[package]] name = "iceberg-catalog-sql" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "async-trait", "iceberg", @@ -4629,7 +4630,7 @@ dependencies = [ [[package]] name = "iceberg-datafusion" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "anyhow", "async-trait", @@ -4645,7 +4646,7 @@ dependencies = [ [[package]] name = "iceberg-storage-opendal" version = "0.10.0" -source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#ca8e8f4bdabc6a2cedc48a2522fe82743b55d6f5" +source = "git+https://github.com/NoahKusaba/iceberg-rust.git?branch=feature%2Fdatafusion-ballista#bc8b8c6ab633c141d703706b721d31eea75f1792" dependencies = [ "anyhow", "async-trait", diff --git a/integrations/iceberg/Cargo.toml b/integrations/iceberg/Cargo.toml index 07afdd38a1..cf627faed2 100644 --- a/integrations/iceberg/Cargo.toml +++ b/integrations/iceberg/Cargo.toml @@ -52,10 +52,10 @@ arrow = { workspace = true } ballista = { path = "../../ballista/client", version = "54.0.0", features = [ "standalone", ] } -# Used by the multi-executor integration test to start a scheduler + several -# in-process executors directly. Feature flags mirror what ballista's -# `standalone` feature already enables, so these reuse the same compiled -# artifacts rather than triggering a separate build. +# Used by the multi-executor integration test and the cluster-iceberg-write +# example to start a scheduler + executors directly. Feature flags mirror what +# ballista's `standalone` feature already enables, so these reuse the same +# compiled artifacts rather than triggering a separate build. ballista-executor = { path = "../../ballista/executor", version = "54.0.0", default-features = false, features = [ "arrow-ipc-optimizations", ] } @@ -67,7 +67,17 @@ log = { workspace = true } # standalone example run against. Not optional: cargo has no optional # dev-dependencies, so this is compiled for any `cargo test` on this crate. testcontainers-modules = { version = "0.15", features = ["minio", "http_wait"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +# `process` and `signal` are for the cluster-iceberg-write example, which runs +# the scheduler and executors as child processes and reaps them on Ctrl-C. +tokio = { workspace = true, features = [ + "macros", + "process", + "rt-multi-thread", + "signal", +] } +# Serves the test scheduler's gRPC service on a pre-bound listener, so the +# multi-executor test can keep a handle on the scheduler's cluster state. +tokio-stream = { workspace = true } [features] default = [] diff --git a/integrations/iceberg/examples/cluster-iceberg-write.rs b/integrations/iceberg/examples/cluster-iceberg-write.rs new file mode 100644 index 0000000000..18a83e56d3 --- /dev/null +++ b/integrations/iceberg/examples/cluster-iceberg-write.rs @@ -0,0 +1,289 @@ +// 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. + +//! # Multi-executor Iceberg cluster +//! +//! A deployed iceberg-ballista cluster is three kinds of process, and each must +//! install the Iceberg plan codecs — codecs are process configuration, not +//! something that travels over the wire: +//! +//! | process | codecs are installed via | +//! |-----------|------------------------------------------------------------| +//! | scheduler | `SchedulerConfig::override_{logical,physical}_codec` | +//! | executor | `ExecutorProcessConfig::override_{logical,physical}_codec` | +//! | client | `register_iceberg_codecs` on its `SessionConfig` | +//! +//! The stock `ballista-scheduler` / `ballista-executor` binaries have no flag +//! for this, so the scheduler and executor are small *custom* binaries. +//! [`run_scheduler`], [`run_executor`] and [`run_client`] below are the complete +//! recipe: each is what the corresponding `main.rs` contains. Notice that only +//! the client is configured with the catalog — the `IcebergCatalogConfig` +//! travels inside the serialized plans, and scheduler/executors rebuild their +//! catalog connections from it. (It includes storage credentials, so run the +//! cluster on a trusted/TLS network.) +//! +//! Everything below the recipe is demo plumbing: this example re-invokes itself +//! as `scheduler`/`executor` child processes and starts a dockerized Iceberg +//! REST catalog + MinIO, so one command runs a real one-scheduler, two-executor +//! cluster through a distributed INSERT and SELECT: +//! +//! ```bash +//! cargo run -p iceberg-ballista --example cluster-iceberg-write +//! ``` +//! +//! All it needs is a running docker daemon. The child processes are killed and +//! the containers removed on the way out, including on `Ctrl-C`; only a hard +//! kill of the demo can leave a scheduler and two executors behind. + +// Shared with the integration tests rather than duplicated. +#[path = "../tests/fixture/mod.rs"] +mod fixture; + +use std::collections::HashMap; +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; + +use ballista::datafusion::execution::SessionStateBuilder; +use ballista::datafusion::prelude::{SessionConfig, SessionContext}; +use ballista::prelude::{SessionConfigExt, SessionContextExt}; +use ballista_core::config::TaskSchedulingPolicy; +use ballista_core::serde::protobuf::scheduler_grpc_client::SchedulerGrpcClient; +use ballista_executor::executor_process::{ + ExecutorProcessConfig, start_executor_process, +}; +use ballista_scheduler::cluster::BallistaCluster; +use ballista_scheduler::config::SchedulerConfig; +use ballista_scheduler::scheduler_process::start_server; +use iceberg::NamespaceIdent; +use iceberg_ballista::{ + IcebergCatalogConfig, IcebergLogicalCodec, IcebergPhysicalCodec, + register_iceberg_codecs, register_iceberg_table, +}; +use tokio::process::{Child, Command}; + +type ExampleResult = Result>; + +/// ## 1. The scheduler binary +/// +/// Decodes the client's logical plan (which carries the Iceberg table provider) +/// and encodes physical plan fragments for the executors, so it needs both +/// codecs. +async fn run_scheduler(port: u16) -> ExampleResult<()> { + let config = Arc::new(SchedulerConfig { + bind_port: port, + // Pull-based only so the demo need not wait for executors (see + // `run_demo`); a real deployment keeps the PushStaged default, and + // whichever it picks the executors must match. + scheduling_policy: TaskSchedulingPolicy::PullStaged, + // The same pair of codecs the executors install below. + override_logical_codec: Some(Arc::new(IcebergLogicalCodec::default())), + override_physical_codec: Some(Arc::new(IcebergPhysicalCodec::default())), + ..Default::default() + }); + let cluster = BallistaCluster::new_from_config(&config).await?; + let address = format!("{}:{}", config.bind_host, config.bind_port).parse()?; + start_server(cluster, address, config).await?; + Ok(()) +} + +/// ## 2. The executor binary +/// +/// Decodes the plan fragments the scheduler ships it — the Iceberg +/// scan/write/commit nodes. Without the codecs, every Iceberg query fails at +/// decode with an unknown-extension error. +async fn run_executor( + scheduler_port: u16, + flight_port: u16, + grpc_port: u16, +) -> ExampleResult<()> { + let config = ExecutorProcessConfig { + // Ports are parameters only because the demo packs several executors + // onto one host; a real deployment keeps the defaults. + port: flight_port, + grpc_port, + scheduler_port, + // Tolerate coming up before the scheduler does. + scheduler_connect_timeout_seconds: 30, + // Must agree with the scheduler's policy above. + task_scheduling_policy: TaskSchedulingPolicy::PullStaged, + override_logical_codec: Some(Arc::new(IcebergLogicalCodec::default())), + override_physical_codec: Some(Arc::new(IcebergPhysicalCodec::default())), + ..Default::default() + }; + start_executor_process(Arc::new(config)).await?; + Ok(()) +} + +/// ## 3. The client +/// +/// Any DataFusion application: install the codecs on the session config, +/// connect to the scheduler, register the table with its catalog config — then +/// plain SQL runs distributed. +async fn run_client( + scheduler_url: &str, + catalog_props: HashMap, + namespace: NamespaceIdent, + table: String, +) -> ExampleResult<()> { + let config = register_iceberg_codecs( + SessionConfig::new_with_ballista().with_target_partitions(4), + ); + let state = SessionStateBuilder::new() + .with_config(config) + .with_default_features() + .build(); + let ctx = SessionContext::remote_with_state(scheduler_url, state).await?; + + let catalog_config = IcebergCatalogConfig::new("rest", "rest", catalog_props); + register_iceberg_table(&ctx, "events", catalog_config, namespace, table).await?; + + // IcebergWriteExec runs on the executors; IcebergCommitExec appends one + // atomic snapshot. + println!("== INSERT =="); + ctx.sql( + "INSERT INTO events VALUES \ + (1, 'alice'), (2, 'bob'), (3, 'carol'), \ + (4, 'dave'), (5, 'erin'), (6, 'frank')", + ) + .await? + .show() + .await?; + + println!("== SELECT =="); + ctx.sql("SELECT id, name FROM events ORDER BY id") + .await? + .show() + .await?; + + Ok(()) +} + +// =========================================================================== +// Demo plumbing — not part of the recipe. +// +// One binary, three roles: with no arguments it runs the demo, spawning itself +// as the scheduler and executor processes, so each role genuinely runs as its +// own OS process talking gRPC. +// =========================================================================== + +const N_EXECUTORS: usize = 2; + +const USAGE: &str = "usage: cluster-iceberg-write [scheduler | executor ]"; + +#[tokio::main] +async fn main() -> ExampleResult<()> { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .try_init(); + + let args: Vec = std::env::args().skip(1).collect(); + match args.iter().map(String::as_str).collect::>()[..] { + [] => run_demo().await, + ["scheduler", port] => run_scheduler(port.parse()?).await, + ["executor", scheduler_port, flight_port, grpc_port] => { + run_executor( + scheduler_port.parse()?, + flight_port.parse()?, + grpc_port.parse()?, + ) + .await + } + _ => Err(USAGE.into()), + } +} + +async fn run_demo() -> ExampleResult<()> { + // Iceberg REST catalog + MinIO in docker, removed when this drops. + println!("== starting Iceberg REST catalog and MinIO =="); + let catalog_fixture = fixture::IcebergFixture::start().await; + let props = catalog_fixture.props(); + let (namespace, table) = fixture::create_demo_table(&props).await; + + let scheduler_port = free_port()?; + println!("== starting scheduler process on port {scheduler_port} =="); + let mut scheduler = spawn_role(&["scheduler", &scheduler_port.to_string()])?; + let scheduler_url = format!("http://localhost:{scheduler_port}"); + wait_for_scheduler(&scheduler_url, &mut scheduler).await?; + + // No need to wait for the executors: under pull-based scheduling, queued + // tasks simply wait until an executor registers and polls for work. + let mut executors = Vec::with_capacity(N_EXECUTORS); + for i in 0..N_EXECUTORS { + println!("== starting executor process {i} =="); + executors.push(spawn_role(&[ + "executor", + &scheduler_port.to_string(), + &free_port()?.to_string(), + &free_port()?.to_string(), + ])?); + } + + // Returning normally — on Ctrl-C too — drops the children and the fixture, + // killing the processes and removing the containers. + tokio::select! { + result = run_client(&scheduler_url, props, namespace, table) => result, + _ = tokio::signal::ctrl_c() => { + println!("== interrupted, shutting the cluster down =="); + Ok(()) + } + } +} + +/// Asks the OS for a free port. Racy in principle (another process could grab +/// it before the child binds), which is fine for a demo — and unavoidable, as +/// an executor registers the port it was configured with, not one it was +/// assigned by binding to `:0`. +fn free_port() -> ExampleResult { + Ok(std::net::TcpListener::bind("127.0.0.1:0")? + .local_addr()? + .port()) +} + +/// Re-invokes this example binary with `args`, e.g. `["scheduler", "50050"]`. +/// The child is killed when the returned handle drops. +fn spawn_role(args: &[&str]) -> ExampleResult { + Ok(Command::new(std::env::current_exe()?) + .args(args) + .kill_on_drop(true) + .spawn()?) +} + +/// Waits until the scheduler's gRPC endpoint accepts a connection, bounded so a +/// scheduler that never comes up fails the demo instead of hanging it — and +/// reporting the child's exit status rather than a timeout if it died during +/// startup (a taken port, say). +async fn wait_for_scheduler( + scheduler_url: &str, + scheduler: &mut Child, +) -> ExampleResult<()> { + for _ in 0..100 { + if SchedulerGrpcClient::connect(scheduler_url.to_string()) + .await + .is_ok() + { + return Ok(()); + } + if let Some(status) = scheduler.try_wait()? { + return Err( + format!("scheduler process exited during startup: {status}").into() + ); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(format!("scheduler at {scheduler_url} unreachable after 10s").into()) +} diff --git a/integrations/iceberg/examples/standalone-iceberg-write.rs b/integrations/iceberg/examples/standalone-iceberg-write.rs index c240ee275f..cdd02989a6 100644 --- a/integrations/iceberg/examples/standalone-iceberg-write.rs +++ b/integrations/iceberg/examples/standalone-iceberg-write.rs @@ -33,74 +33,13 @@ #[path = "../tests/fixture/mod.rs"] mod fixture; -use std::collections::HashMap; -use std::sync::Arc; - use ballista::datafusion::common::Result; use ballista::datafusion::execution::SessionStateBuilder; use ballista::datafusion::prelude::{SessionConfig, SessionContext}; use ballista::prelude::{SessionConfigExt, SessionContextExt}; -use iceberg::spec::{NestedField, PrimitiveType, Schema, Type}; -use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, TableCreation, TableIdent}; use iceberg_ballista::{ IcebergCatalogConfig, register_iceberg_codecs, register_iceberg_table, }; -use iceberg_catalog_rest::RestCatalogBuilder; -use iceberg_storage_opendal::OpenDalStorageFactory; - -/// Creates the demo namespace and table in the catalog if they do not exist. -async fn ensure_table( - props: &HashMap, -) -> Result<(NamespaceIdent, String)> { - let catalog = RestCatalogBuilder::default() - .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 { - customized_credential_load: None, - })) - .load("rest", props.clone()) - .await - .expect("build rest catalog"); - - let namespace = NamespaceIdent::new("ballista_demo".to_string()); - if !catalog - .namespace_exists(&namespace) - .await - .expect("ns exists") - { - catalog - .create_namespace(&namespace, HashMap::new()) - .await - .expect("create namespace"); - } - - let table_ident = TableIdent::new(namespace.clone(), "events".to_string()); - if !catalog - .table_exists(&table_ident) - .await - .expect("table exists") - { - let schema = Schema::builder() - .with_schema_id(0) - .with_fields(vec![ - NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)) - .into(), - NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)) - .into(), - ]) - .build() - .expect("build schema"); - let creation = TableCreation::builder() - .name("events".to_string()) - .schema(schema) - .properties(HashMap::new()) - .build(); - catalog - .create_table(&namespace, creation) - .await - .expect("create table"); - } - - Ok((namespace, "events".to_string())) -} #[tokio::main] async fn main() -> Result<()> { @@ -113,8 +52,8 @@ async fn main() -> Result<()> { let catalog_fixture = fixture::IcebergFixture::start().await; let props = catalog_fixture.props(); - // Make sure the target table exists in the catalog. - let (namespace, table) = ensure_table(&props).await?; + // Create the target table in the fresh catalog. + let (namespace, table) = fixture::create_demo_table(&props).await; // Build a Ballista session config with the Iceberg codecs installed, so the // standalone scheduler and executor can serialize the Iceberg plan nodes. diff --git a/integrations/iceberg/src/lib.rs b/integrations/iceberg/src/lib.rs index 4469f6a61b..69da378c0d 100644 --- a/integrations/iceberg/src/lib.rs +++ b/integrations/iceberg/src/lib.rs @@ -73,11 +73,19 @@ pub use crate::physical_codec::IcebergPhysicalCodec; /// In a standalone cluster the scheduler and executor both derive their codecs /// from this config, so one call suffices. For a separately deployed scheduler /// and executor, set the same codecs on their process configs -/// (`override_logical_codec` / `override_physical_codec`). +/// (`override_logical_codec` / `override_physical_codec`); the +/// `cluster-iceberg-write` example shows both custom binaries. pub fn register_iceberg_codecs(config: SessionConfig) -> SessionConfig { + let logical = config.ballista_logical_extension_codec(); + let physical = config.ballista_physical_extension_codec(); + config - .with_ballista_logical_extension_codec(Arc::new(IcebergLogicalCodec::default())) - .with_ballista_physical_extension_codec(Arc::new(IcebergPhysicalCodec::default())) + .with_ballista_logical_extension_codec(Arc::new(IcebergLogicalCodec::new( + logical, + ))) + .with_ballista_physical_extension_codec(Arc::new(IcebergPhysicalCodec::new( + physical, + ))) } /// Builds a catalog-backed [`IcebergTableProvider`](iceberg_datafusion::IcebergTableProvider) diff --git a/integrations/iceberg/src/physical_codec.rs b/integrations/iceberg/src/physical_codec.rs index 47204f076b..6449faef32 100644 --- a/integrations/iceberg/src/physical_codec.rs +++ b/integrations/iceberg/src/physical_codec.rs @@ -164,12 +164,8 @@ impl PhysicalExtensionCodec for IcebergPhysicalCodec { IcebergPhysicalNode::Write { table_ref } => { let (config, table) = table_ref.into_parts(); let table_obj = load_table(&config, &table)?; - // Writes always target the current schema — a snapshot-pinned - // provider is read-only. - let arrow_schema = snapshot_arrow_schema(&table_obj, None) - .map_err(to_datafusion_error)?; let input = single_input(inputs, "IcebergWriteExec")?; - let write = IcebergWriteExec::new(table_obj, input, arrow_schema) + let write = IcebergWriteExec::new(table_obj, input) .with_catalog_config(Some(config)); Ok(Arc::new(write)) } diff --git a/integrations/iceberg/tests/distributed_read_write.rs b/integrations/iceberg/tests/distributed_read_write.rs index 3ea9009a8f..319630f89d 100644 --- a/integrations/iceberg/tests/distributed_read_write.rs +++ b/integrations/iceberg/tests/distributed_read_write.rs @@ -15,12 +15,11 @@ // specific language governing permissions and limitations // under the License. -//! End-to-end distributed Iceberg write/read test. +//! End-to-end distributed Iceberg write/read tests. //! //! Each test starts its own Iceberg REST catalog + MinIO with testcontainers -//! (see [`fixture`]), so this needs a working docker daemon. That is why it -//! sits behind the `integration-tests` feature and is not part of a plain -//! `cargo test`: +//! (see [`fixture`]), so a docker daemon is required — hence the +//! `integration-tests` feature gate: //! //! ```bash //! cargo test -p iceberg-ballista --features integration-tests --test distributed_read_write @@ -30,7 +29,8 @@ mod fixture; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -39,22 +39,30 @@ use ballista::datafusion::assert_batches_eq; use ballista::datafusion::execution::{SessionState, SessionStateBuilder}; use ballista::datafusion::prelude::{SessionConfig, SessionContext}; use ballista::prelude::{SessionConfigExt, SessionContextExt}; +use ballista_core::ConfigProducer; +use ballista_core::config::TaskSchedulingPolicy; +use ballista_core::serde::BallistaCodec; use ballista_core::serde::protobuf::scheduler_grpc_client::SchedulerGrpcClient; +use ballista_core::serde::protobuf::scheduler_grpc_server::SchedulerGrpcServer; +use ballista_core::serde::protobuf::task_status; +use ballista_core::utils::{GrpcServerConfig, create_grpc_server}; use ballista_executor::new_standalone_executor_from_state; -use ballista_scheduler::standalone::new_standalone_scheduler_from_state; +use ballista_scheduler::cluster::BallistaCluster; +use ballista_scheduler::config::SchedulerConfig; +use ballista_scheduler::metrics::default_metrics_collector; +use ballista_scheduler::scheduler_server::{SchedulerServer, SessionBuilder}; +use datafusion_proto::protobuf::{LogicalPlanNode, PhysicalPlanNode}; use iceberg::spec::{ NestedField, PrimitiveType, Schema, Transform, Type, UnboundPartitionField, UnboundPartitionSpec, }; use iceberg::transaction::{AddColumn, ApplyTransactionAction, Transaction}; -use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, TableCreation, TableIdent}; +use iceberg::{Catalog, NamespaceIdent, TableCreation, TableIdent}; use iceberg_ballista::{ IcebergCatalogConfig, register_iceberg_catalog, register_iceberg_codecs, register_iceberg_table, }; -use iceberg_catalog_rest::RestCatalogBuilder; use iceberg_datafusion::IcebergTableProvider; -use iceberg_storage_opendal::OpenDalStorageFactory; use crate::fixture::IcebergFixture; @@ -76,16 +84,6 @@ async fn run_sql(ctx: &SessionContext, sql: &str) -> Vec { .expect("run sql") } -async fn build_rest_catalog(props: &HashMap) -> impl Catalog + use<> { - RestCatalogBuilder::default() - .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 { - customized_credential_load: None, - })) - .load("rest", props.clone()) - .await - .expect("build rest catalog") -} - /// Creates the test namespace. Each test owns its catalog, so this never races /// another creator. async fn create_namespace(catalog: &impl Catalog) -> NamespaceIdent { @@ -105,7 +103,7 @@ async fn create_table_with( schema: Schema, partition_spec: Option, ) -> NamespaceIdent { - let catalog = build_rest_catalog(props).await; + let catalog = fixture::rest_catalog(props).await; let namespace = create_namespace(&catalog).await; let builder = TableCreation::builder() @@ -146,7 +144,7 @@ async fn current_snapshot_id( namespace: &NamespaceIdent, table_name: &str, ) -> i64 { - build_rest_catalog(props) + fixture::rest_catalog(props) .await .load_table(&TableIdent::new(namespace.clone(), table_name.to_string())) .await @@ -156,9 +154,8 @@ async fn current_snapshot_id( .expect("table has a snapshot") } -/// Creates a table partitioned by `region` (identity). A distributed INSERT then -/// fans the rows out to one writer per region — exercising the partition-value -/// expression (`PartitionExpr`) serialization across the cluster. +/// Creates a table partitioned by `region` (identity), so an INSERT injects a +/// partition-value expression (`PartitionExpr`) into the physical plan. async fn create_partitioned_table( props: &HashMap, table_name: &str, @@ -189,6 +186,11 @@ async fn create_partitioned_table( create_table_with(props, table_name, schema, Some(partition_spec)).await } +/// The baseline: distributed INSERT and read-back on standalone Ballista. +/// +/// Writes and reads are interleaved, because every query must plan against the +/// snapshot current at that moment — a provider that cached its table metadata +/// would keep serving the first read's empty snapshot. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn distributed_insert_and_read() { let _ = env_logger::builder().is_test(true).try_init(); @@ -256,7 +258,16 @@ async fn distributed_insert_and_read() { // the snapshot current at *that* moment — the codec then pins it for the // executors. A provider (or a cached catalog/table) that went stale after // the first read would still report 3 rows here. - run_sql(&ctx, "INSERT INTO events VALUES (4, 'dave'), (5, 'erin')").await; + // + // A `UNION ALL` of `VALUES` branches gives the write one input partition per + // branch, so this one runs as concurrent writer tasks rather than a single + // one — see [`multi_executor_write_fans_out_across_executors`]. + run_sql( + &ctx, + "INSERT INTO events SELECT * FROM (VALUES (4, 'dave')) \ + UNION ALL SELECT * FROM (VALUES (5, 'erin'))", + ) + .await; // The re-read must observe the interposed insert: both the original and the // newly inserted rows. @@ -301,46 +312,95 @@ async fn distributed_insert_and_read() { ); } -/// Distributed correctness on a real multi-executor cluster, writing a -/// **partitioned** table. -/// -/// Where [`distributed_insert_and_read`] uses standalone Ballista (one in-process -/// executor) and an unpartitioned table, this stands up a single scheduler with -/// **several in-process executors** and writes a table partitioned by `region`. A -/// partitioned write injects a partition-value expression (`PartitionExpr`) into -/// the physical plan, so this exercises that expression's serialization through -/// the codec on top of the plan-node serialization — and fans the rows out to one -/// writer per region across the executors. -/// -/// The assertions target the correctness properties of a distributed, multi-writer -/// write: -/// 1. the write commits exactly **one** atomic snapshot (not one per task), -/// 2. the parallel writers contributed multiple data files (one per region), and -/// 3. every input row lands exactly once (no loss, no duplication). -#[tokio::test(flavor = "multi_thread", worker_threads = 8)] -async fn parallel_multi_executor_insert_commits_all_rows() { - let _ = env_logger::builder().is_test(true).try_init(); - - const N_EXECUTORS: usize = 2; - const SLOTS_PER_EXECUTOR: usize = 2; - const WRITE_PARTITIONS: usize = 8; - const REGIONS: [&str; 4] = ["a", "b", "c", "d"]; - // REGIONS.len() * 3. - const TOTAL_ROWS: i32 = 12; - - let fixture = IcebergFixture::start().await; - let props = fixture.props(); - let table_name = "parallel_events".to_string(); - let namespace = create_partitioned_table(&props, &table_name).await; - - // --- Bring up one scheduler + N executors in-process (real multi-executor) --- - let state = iceberg_session_state( - SessionConfig::new_with_ballista().with_target_partitions(WRITE_PARTITIONS), +/// Like `ballista_scheduler::standalone::new_standalone_scheduler_from_state`, +/// but also returns the [`BallistaCluster`] that helper discards: its job state +/// holds the execution graphs, which record who ran each task. +async fn start_scheduler_with_cluster( + session_state: &SessionState, +) -> (SocketAddr, BallistaCluster) { + let codec = BallistaCodec::new( + session_state.config().ballista_logical_extension_codec(), + session_state.config().ballista_physical_extension_codec(), ); + let max_message_size = session_state + .config() + .ballista_grpc_client_max_message_size(); + + let session_config = session_state.config().clone(); + let state_for_builder = session_state.clone(); + let session_builder: SessionBuilder = + Arc::new(move |_| Ok(state_for_builder.clone())); + let config_producer: ConfigProducer = Arc::new(move || session_config.clone()); + + let cluster = + BallistaCluster::new_memory("localhost:50050", session_builder, config_producer); + + let mut scheduler_server: SchedulerServer = + SchedulerServer::new( + "localhost:50050".to_owned(), + cluster.clone(), + codec, + Arc::new( + SchedulerConfig::default() + .with_scheduler_policy(TaskSchedulingPolicy::PullStaged), + ), + default_metrics_collector().expect("metrics collector"), + ); + scheduler_server + .init() + .await + .expect("init scheduler server"); - let scheduler_addr = new_standalone_scheduler_from_state(&state) + let server = SchedulerGrpcServer::new(scheduler_server.clone()) + .max_decoding_message_size(max_message_size) + .max_encoding_message_size(max_message_size); + let listener = tokio::net::TcpListener::bind("localhost:0") .await - .expect("start scheduler"); + .expect("bind scheduler port"); + let addr = listener.local_addr().expect("scheduler address"); + tokio::spawn( + create_grpc_server(&GrpcServerConfig::default()) + .add_service(server) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new( + listener, + )), + ); + (addr, cluster) +} + +/// Executor ids that completed at least one task, across every job in the +/// cluster's state. +async fn executors_that_ran_tasks(cluster: &BallistaCluster) -> HashSet { + let job_state = cluster.job_state(); + let mut executors = HashSet::new(); + for job_id in job_state.get_all_jobs().await.expect("list jobs") { + let graph = job_state + .get_execution_graph(&job_id) + .await + .expect("fetch execution graph") + .expect("job has an execution graph"); + for stage in graph.stages().values() { + for task in stage.task_infos().unwrap_or_default() { + if let task_status::Status::Successful(t) = &task.task_status { + executors.insert(t.executor_id.clone()); + } + } + } + } + executors +} + +/// Executors in the multi-executor cluster, and task slots on each. +const N_EXECUTORS: usize = 2; +const SLOTS_PER_EXECUTOR: usize = 2; + +/// One scheduler and [`N_EXECUTORS`] executors, all in-process but each its own +/// gRPC service. Returns a context connected to the scheduler, plus the cluster +/// handle that records task placement. +async fn start_multi_executor_cluster( + state: SessionState, +) -> (SessionContext, BallistaCluster) { + let (scheduler_addr, cluster) = start_scheduler_with_cluster(&state).await; let scheduler_url = format!("http://localhost:{}", scheduler_addr.port()); // Bounded so a scheduler that never comes up fails the test instead of @@ -374,6 +434,41 @@ async fn parallel_multi_executor_insert_commits_all_rows() { let ctx = SessionContext::remote_with_state(&scheduler_url, state) .await .expect("connect to scheduler"); + (ctx, cluster) +} + +/// A **partitioned** write on a real multi-executor cluster: one scheduler, +/// several executors, a table partitioned by `region`, and a `UNION ALL` source +/// that gives the write one writer task per branch. +/// +/// So the partition-value expression (`PartitionExpr`) must survive the codec, +/// and concurrent writers must still produce: +/// +/// 1. exactly **one** atomic snapshot, not one per task, +/// 2. at least one data file per region, all within that snapshot, +/// 3. every input row exactly once — predicate pushdown included. +/// +/// Task *placement* is asserted by +/// [`multi_executor_write_fans_out_across_executors`]. +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn parallel_multi_executor_insert_commits_all_rows() { + let _ = env_logger::builder().is_test(true).try_init(); + + const REGIONS: [&str; 4] = ["a", "b", "c", "d"]; + // REGIONS.len() * 3. + const TOTAL_ROWS: i32 = 12; + // Rows per UNION ALL branch, so the write stage runs + // TOTAL_ROWS / ROWS_PER_BRANCH = 6 concurrent writer tasks. + const ROWS_PER_BRANCH: usize = 2; + + let fixture = IcebergFixture::start().await; + let props = fixture.props(); + let table_name = "parallel_events".to_string(); + let namespace = create_partitioned_table(&props, &table_name).await; + + // --- Bring up one scheduler + N executors in-process (real multi-executor) --- + let state = iceberg_session_state(SessionConfig::new_with_ballista()); + let (ctx, _cluster) = start_multi_executor_cluster(state).await; let catalog_config = IcebergCatalogConfig::new("rest", "rest", props.clone()); register_iceberg_table( @@ -387,24 +482,27 @@ async fn parallel_multi_executor_insert_commits_all_rows() { .expect("register iceberg table"); // --- Distributed, partitioned INSERT across the multi-executor cluster --- - // A serializable VALUES source whose rows span every region; the partitioned - // write fans them out to one writer per region across the executors, which is - // the path that injects PartitionExpr into the physical plan. (A registered - // MemTable would not work here — it's a custom TableProvider that Ballista - // cannot serialize into the logical plan.) - let values = (0..TOTAL_ROWS) + // A serializable VALUES source whose rows span every region, which is the + // path that injects PartitionExpr into the physical plan, split into UNION + // ALL branches so several writer tasks run at once. (A registered MemTable + // would not work here — it's a custom TableProvider that Ballista cannot + // serialize into the logical plan.) + let source = (0..TOTAL_ROWS) .map(|i| { let region = REGIONS[i as usize % REGIONS.len()]; format!("({}, '{region}')", i + 1) }) .collect::>() - .join(", "); - run_sql(&ctx, &format!("INSERT INTO target VALUES {values}")).await; + .chunks(ROWS_PER_BRANCH) + .map(|branch| format!("SELECT * FROM (VALUES {})", branch.join(", "))) + .collect::>() + .join(" UNION ALL "); + run_sql(&ctx, &format!("INSERT INTO target {source}")).await; // (1) The distributed write committed exactly once (a single atomic // snapshot), not one commit per task. Checked straight from the catalog so // it isolates write correctness from the distributed read-back below. - let catalog = build_rest_catalog(&props).await; + let catalog = fixture::rest_catalog(&props).await; let table = catalog .load_table(&TableIdent::new(namespace.clone(), table_name.clone())) .await @@ -415,8 +513,8 @@ async fn parallel_multi_executor_insert_commits_all_rows() { "the distributed write must produce exactly one atomic commit" ); - // (2) The parallel writers each produced a data file (one per region), all - // coalesced into that single commit. + // (2) The writers produced at least one data file per region, all coalesced + // into that single commit. let snapshot = table .metadata() .current_snapshot() @@ -435,8 +533,8 @@ async fn parallel_multi_executor_insert_commits_all_rows() { data_files += manifest.entries().len(); } assert!( - data_files >= 2, - "partitioned write should produce multiple data files, got {data_files}" + data_files >= REGIONS.len(), + "partitioned write should produce at least one data file per region, got {data_files}" ); // (3) Every row landed exactly once: the exact id set 1..=TOTAL_ROWS, no @@ -471,29 +569,105 @@ async fn parallel_multi_executor_insert_commits_all_rows() { ); } -/// A distributed read pinned to an old snapshot must return that snapshot's rows -/// *and* that snapshot's schema — even after the table's schema has changed. +/// A distributed write must **fan out across the executors**, not merely run on +/// a cluster that has several. /// -/// The executor never sees the scheduler's table object; it reloads metadata from -/// the catalog itself. So the pin has to survive both codecs to get there, and an -/// executor that used the table's *current* schema would describe the historical -/// rows wrongly. +/// Placement is invisible in the query result, so it is read from the scheduler: +/// each completed task records the executor that ran it, and the assertion fails +/// if one executor served the whole job. /// -/// One table, five phases, so both directions of schema drift are covered by a -/// single cluster: +/// Fanning out needs a multi-partition source. `IcebergWriteExec` reports +/// `benefits_from_input_partitioning = false`, so nothing repartitions on its +/// behalf — its parallelism is exactly its input's partition count, and one +/// `VALUES` is one partition. Hence the `UNION ALL`: one partition, and so one +/// write task, per branch. +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn multi_executor_write_fans_out_across_executors() { + let _ = env_logger::builder().is_test(true).try_init(); + + const REGIONS: [&str; 4] = ["a", "b", "c", "d"]; + const TOTAL_ROWS: i32 = 12; + // Rows per UNION ALL branch, so the write stage has + // TOTAL_ROWS / ROWS_PER_BRANCH = 6 tasks against the cluster's + // N_EXECUTORS * SLOTS_PER_EXECUTOR = 4 slots. + const ROWS_PER_BRANCH: usize = 2; + + let fixture = IcebergFixture::start().await; + let props = fixture.props(); + let table_name = "fanout_events".to_string(); + let namespace = create_partitioned_table(&props, &table_name).await; + + let state = iceberg_session_state(SessionConfig::new_with_ballista()); + let (ctx, cluster) = start_multi_executor_cluster(state).await; + + let catalog_config = IcebergCatalogConfig::new("rest", "rest", props.clone()); + register_iceberg_table( + &ctx, + "target", + catalog_config, + namespace.clone(), + table_name.clone(), + ) + .await + .expect("register iceberg table"); + + let source = (0..TOTAL_ROWS) + .map(|i| { + let region = REGIONS[i as usize % REGIONS.len()]; + format!("({}, '{region}')", i + 1) + }) + .collect::>() + .chunks(ROWS_PER_BRANCH) + .map(|branch| format!("SELECT * FROM (VALUES {})", branch.join(", "))) + .collect::>() + .join(" UNION ALL "); + run_sql(&ctx, &format!("INSERT INTO target {source}")).await; + + // Every executor completed at least one task. Timing-safe: the write stage + // has more tasks than any one executor has slots, so a single executor can + // never take the whole stage in one bind, and the other polls every 50ms. + let executors_used = executors_that_ran_tasks(&cluster).await; + assert_eq!( + executors_used.len(), + N_EXECUTORS, + "every executor should have completed tasks, got {executors_used:?}" + ); + + // The parallel writers still commit exactly one snapshot with every row. + let catalog = fixture::rest_catalog(&props).await; + let table = catalog + .load_table(&TableIdent::new(namespace.clone(), table_name.clone())) + .await + .expect("load committed table"); + assert_eq!( + table.metadata().snapshots().count(), + 1, + "the distributed write must produce exactly one atomic commit" + ); + assert_batches_eq!( + ["+----+", "| n |", "+----+", "| 12 |", "+----+"], + &run_sql(&ctx, "SELECT count(*) AS n FROM target").await + ); +} + +/// A read pinned to an old snapshot must return that snapshot's rows *and* its +/// schema, even after the table's schema has moved on. +/// +/// Executors never see the scheduler's table object; they reload metadata from +/// the catalog themselves. The pin must therefore survive both codecs to reach +/// them, or an executor describes historical rows with the current schema. +/// +/// One table, five phases, covering both directions of drift: /// /// 1. write `{id, name}` -> snapshot 1 /// 2. add `email`, write again -> snapshot 2; a current read sees all six rows -/// 3. read pinned to snapshot 1 — current schema has a column it lacks +/// 3. read pinned to snapshot 1 — the table has a column that snapshot lacks /// 4. drop `name`, leaving `{id, email}` -/// 5. read pinned to snapshot 2 — that snapshot has a column current lacks -/// -/// Phase 3 must come before the drop, while the schemas still differ. Phase 5 is -/// the one an executor can't get right by accident: the current schema has no -/// `name` to project, though the pinned snapshot's rows have the values. +/// 5. read pinned to snapshot 2 — that snapshot has a column the table lacks /// -/// Phase 4 drops `name` rather than `email` to work around an unrelated -/// iceberg-rust bug; see the comment there. +/// Phase 3 must precede the drop, while the schemas still differ. Phase 5 is the +/// one no executor gets right by accident: the current schema has no `name` to +/// project, though the pinned rows carry the values. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn distributed_time_travel_pins_snapshot_schema() { let _ = env_logger::builder().is_test(true).try_init(); @@ -533,7 +707,7 @@ async fn distributed_time_travel_pins_snapshot_schema() { // Evolve the schema: add an `email` column. Snapshot 1 keeps referencing the // two-column schema, so current and historical schemas now differ. - let catalog: Arc = Arc::new(build_rest_catalog(&props).await); + let catalog: Arc = Arc::new(fixture::rest_catalog(&props).await); let table_ident = TableIdent::new(namespace.clone(), table_name.clone()); let table = catalog.load_table(&table_ident).await.expect("load table"); let tx = Transaction::new(&table); diff --git a/integrations/iceberg/tests/fixture/mod.rs b/integrations/iceberg/tests/fixture/mod.rs index ec274f1795..4d1a510fa1 100644 --- a/integrations/iceberg/tests/fixture/mod.rs +++ b/integrations/iceberg/tests/fixture/mod.rs @@ -16,18 +16,24 @@ // under the License. //! Docker fixture for the Iceberg integration tests and the -//! `standalone-iceberg-write` example: an Iceberg REST catalog backed by MinIO. +//! `standalone-iceberg-write` / `cluster-iceberg-write` examples: an Iceberg +//! REST catalog backed by MinIO. //! //! Each [`IcebergFixture`] is a private catalog on its own docker network, so //! tests holding one share no namespace, table name, or catalog state. The -//! example includes this module by path, so it must not depend on anything +//! examples include this module by path, so it must not depend on anything //! test-only. #![allow(dead_code)] use std::collections::HashMap; +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use iceberg::spec::{NestedField, PrimitiveType, Schema, Type}; +use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, TableCreation}; +use iceberg_catalog_rest::RestCatalogBuilder; +use iceberg_storage_opendal::OpenDalStorageFactory; use testcontainers_modules::minio::MinIO; use testcontainers_modules::testcontainers::core::wait::HttpWaitStrategy; use testcontainers_modules::testcontainers::core::{ @@ -130,6 +136,56 @@ impl IcebergFixture { } } +/// A REST catalog client addressing `props`, with its storage wired to the +/// fixture's MinIO. +pub async fn rest_catalog(props: &HashMap) -> impl Catalog + use<> { + RestCatalogBuilder::default() + .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 { + customized_credential_load: None, + })) + .load("rest", props.clone()) + .await + .expect("build rest catalog") +} + +/// Creates `ballista_demo.events (id int, name string)` and returns its +/// namespace and name. +/// +/// For the examples: each starts its own fixture, so the catalog is always +/// empty and this never has to reconcile with an existing table. +pub async fn create_demo_table( + props: &HashMap, +) -> (NamespaceIdent, String) { + let catalog = rest_catalog(props).await; + + let namespace = NamespaceIdent::new("ballista_demo".to_string()); + catalog + .create_namespace(&namespace, HashMap::new()) + .await + .expect("create namespace"); + + let schema = Schema::builder() + .with_schema_id(0) + .with_fields(vec![ + NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(), + NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)) + .into(), + ]) + .build() + .expect("build schema"); + let creation = TableCreation::builder() + .name("events".to_string()) + .schema(schema) + .properties(HashMap::new()) + .build(); + catalog + .create_table(&namespace, creation) + .await + .expect("create table"); + + (namespace, "events".to_string()) +} + /// Creates the warehouse bucket with the `mc` the MinIO image ships. async fn create_bucket(minio: &ContainerAsync) { // The image preconfigures a `local` alias without credentials.